import { Type, type Static } from "typebox"; import { dirname, join } from "node:path"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { composePanePrompt, resolvePaneCompletionProtocol, type PaneBackend } from "./pane-completion-protocol.ts"; import { resolveIdentityDelivery, identityRoutesToSystemPrompt } from "./identity-delivery.ts"; import { emitDiagnostic, type DiagnosticContext } from "../diagnostics/diagnostics.ts"; /** * Launch-spec normalization for subagent launches. * * This module extracts the launch-time resolution logic from `launchSubagent()`: * agent defaults, effective model/tools/skills/thinking/cli, working directory * + config-root resolution, session-file placement, system-prompt handling, * fork/lineage session-mode logic, skill expansion, and deny-tool resolution. * * Pane and headless backends both consume the `ResolvedLaunchSpec` so they see * identical normalization semantics. Pane-only side-effects (`createSurface`, * `sendLongCommand`, widget updates) stay in `index.ts`. */ // ── Types ────────────────────────────────────────────────────────────────── export type SubagentSessionMode = "standalone" | "lineage-only" | "fork"; /** * CLI-agnostic execution policy for subagent launches. * * - `guarded`: prefer the backend's safest practical autonomous mode. For * Claude this maps to `--permission-mode auto`, keeping the permission * classifier in the loop for risky actions. * - `unrestricted`: explicitly opt into bypass/full-access behavior for * trusted, sandboxed, or otherwise controlled runs. For Claude this restores * the legacy bypass path (`--dangerously-skip-permissions` for panes, * `--permission-mode bypassPermissions` for headless). * * The policy is intentionally not modeled as a Claude-specific permission-mode * setting: future backends (Codex, OpenCode, pi) map the same two values onto * their own safety controls. See README "Execution policy" for the future * backend mapping table. */ export type ExecutionPolicy = "guarded" | "unrestricted"; /** Resolved policy plus which input it came from, for diagnostics/tests. */ export type ExecutionPolicySource = "default" | "agent" | "params"; export const DEFAULT_EXECUTION_POLICY: ExecutionPolicy = "guarded"; function parseExecutionPolicy(value: string | undefined): ExecutionPolicy | undefined { if (value === "guarded" || value === "unrestricted") return value; return undefined; } export interface AgentDefaults { model?: string; tools?: string; skills?: string; thinking?: string; denyTools?: string; spawning?: boolean; autoExit?: boolean; interactive?: boolean; systemPromptMode?: "append" | "replace"; sessionMode?: SubagentSessionMode; cwd?: string; cli?: string; body?: string; disableModelInvocation?: boolean; executionPolicy?: ExecutionPolicy; } // Re-declared here (rather than imported from index.ts) so launch-spec.ts has // no dependency edge back into the pane module. index.ts imports this type // via its own re-export for API stability. export const SubagentParams = Type.Object({ name: Type.String({ description: "Display name for the subagent" }), task: Type.String({ description: "Task/prompt for the sub-agent" }), agent: Type.Optional( Type.String({ description: "Agent name to load defaults from. Reads ~/.pi/agent/agents/.md for model, tools, skills.", }), ), systemPrompt: Type.Optional( Type.String({ description: "Appended to system prompt (role instructions)" }), ), model: Type.Optional(Type.String({ description: "Model override (overrides agent default)" })), skills: Type.Optional( Type.String({ description: "Comma-separated skills (overrides agent default)" }), ), tools: Type.Optional( Type.String({ description: "Comma-separated tools (overrides agent default)" }), ), cwd: Type.Optional( Type.String({ description: "Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.", }), ), fork: Type.Optional( Type.Boolean({ description: "Force the full-context fork mode for this spawn. The sub-agent inherits the current session conversation, overriding any agent frontmatter session-mode.", }), ), resumeSessionId: Type.Optional( Type.String({ description: "Resume a previous Claude Code session by its ID. Loads the conversation history and continues where it left off. The session ID is returned in details of every claude tool call. Use this to retry cancelled runs or ask follow-up questions.", }), ), cli: Type.Optional( Type.String({ description: "CLI to launch for this subagent. One of 'pi' (default), 'claude', or 'codex'. Overrides the agent frontmatter `cli` field.", }), ), thinking: Type.Optional( Type.String({ description: "Thinking/effort override. Values: off, minimal, low, medium, high, xhigh. For pi: folded into the model string as `:`. For Claude: mapped to --effort (off/minimal/low→low, medium, high, xhigh→max). For Codex: mapped to -c model_reasoning_effort=; unsupported values (e.g. off) are omitted. Overrides agent frontmatter.", }), ), focus: Type.Optional( Type.Boolean({ description: "Whether the newly spawned pane grabs focus. Default true. Only honored on tmux today (other backends ignore). Orchestration wrappers default this to false for parallel, true for serial.", }), ), interactive: Type.Optional( Type.Boolean({ description: "Mark the subagent as interactive (long-running, user drives the conversation in its own pane). When true, the main session is not woken by status transitions (stalled/recovered) for this subagent. If omitted, falls back to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit` (agents that auto-exit are autonomous and get stall pings; agents that don't are interactive and stay quiet).", }), ), executionPolicy: Type.Optional( Type.String({ description: "CLI-agnostic execution policy. 'guarded' (default) prefers the backend's safest practical autonomous mode; for Claude this is --permission-mode auto, keeping the permission classifier in the loop. 'unrestricted' opts into bypass/full-access behavior for trusted, sandboxed runs; for Claude this restores --dangerously-skip-permissions (pane) / --permission-mode bypassPermissions (headless). For Codex: guarded → --sandbox workspace-write with non-interactive approval_policy=never (headless) / --ask-for-approval on-request (pane); unrestricted → --dangerously-bypass-approvals-and-sandbox. Overrides agent frontmatter `execution-policy`.", }), ), }); export type SubagentParamsType = Static; export interface ResolvedLaunchSpec { name: string; task: string; agent: string | undefined; effectiveCli: "pi" | "claude" | string; effectiveModel: string | undefined; /** Claude-only projection of `effectiveModel`: strips a leading `/` prefix. */ claudeModelArg: string | undefined; /** Codex-only projection of `effectiveModel`: strips a leading `/` prefix (same contract as claudeModelArg). */ codexModelArg: string | undefined; effectiveTools: string | undefined; effectiveSkills: string | undefined; effectiveThinking: string | undefined; /** Skill names expanded to `/skill:` strings. */ skillPrompts: string[]; effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string; /** Env-var prefix map for `PI_CODING_AGENT_DIR` when propagation applies. Empty object otherwise. */ configRootEnv: Record; identity: string | null; identityInSystemPrompt: boolean; systemPromptMode: "append" | "replace" | undefined; fullTask: string; /** Task body for Claude backends — NEVER includes the `roleBlock`; identity reaches Claude via the system-prompt flag. */ claudeTaskBody: string; /** * Finite pane-backend discriminator, projected from `effectiveCli`. This is * the launcher-visible pane backend union that keys the completion-protocol * seam: the pane launch dispatch in index.ts switches on this field, and * `resolvePaneCompletionProtocol`/`PANE_COMPLETION_PROTOCOLS` are keyed on the * SAME `PaneBackend` union — so a new pane backend breaks both the dispatch * (`assertNever`) and the protocol table until handled. Unknown CLIs map to * "pi" to preserve the legacy fall-through. Pane-scoped: headless dispatch * keys on `effectiveCli`, which is left unchanged. */ paneBackend: PaneBackend; /** * CLI-neutral core: the agent identity role block + task payload, FREE of * completion-protocol wording (no modeHint/summaryInstruction). The Codex * pane composer consumes this; `fullTask`/`claudeTaskBody` survive as derived * outputs for the other consumers. Mirrors fullTask's identity handling: * roleBlock is included only when identity is not routed via the system * prompt; fork/lineage delivery uses the bare task. */ neutralCore: string; sessionMode: SubagentSessionMode; seededSessionMode: "lineage-only" | "fork" | null; inheritsConversationContext: boolean; taskDelivery: "direct" | "artifact"; subagentSessionFile: string; artifactDir: string; autoExit: boolean; effectiveInteractive: boolean; /** * Claude-only system-prompt addendum that ends with a `subagent_done` * instruction. Populated only on the Claude pane path (`effectiveCli === * "claude"`); null on every other path. The pane launch site folds this * into the `identity` value passed to `buildClaudeCmdParts`. */ claudeCompletionAddendum: string | null; denySet: Set; resumeSessionId: string | undefined; focus: boolean | undefined; /** Resolved CLI-agnostic execution policy. Defaults to `guarded`. */ effectiveExecutionPolicy: ExecutionPolicy; /** Which input produced `effectiveExecutionPolicy` (for diagnostics/tests). */ executionPolicySource: ExecutionPolicySource; agentDefs: AgentDefaults | null; } // ── Constants ────────────────────────────────────────────────────────────── /** Tools gated by `spawning: false`. */ const SPAWNING_TOOLS = new Set([ "subagent", "subagents_list", "subagent_resume", "subagent_run_serial", "subagent_run_parallel", "subagent_run_cancel", "subagent_interrupt", ]); /** * Lifecycle tools injected by the `subagent-done.ts` pi extension. Under a * restrictive `--tools` allowlist pi would otherwise strip these, breaking the * block-lifecycle contract: `caller_ping` signals the parent (Phase 2), and * `subagent_done` signals terminal completion (Phase 1). Both must be present * whenever we emit `--tools` so orchestration lifecycle invariants hold even * when the agent frontmatter declares a tight `tools:` list. */ const PI_LIFECYCLE_TOOLS = ["caller_ping", "subagent_done"] as const; /** * Resolve the argument for pi's `--tools` flag from an agent's `effectiveTools` * declaration. Returns `undefined` when no restriction should be applied (and * pi should run with its default tool surface). * * Pi applies `--tools` to built-in, extension, and custom tools, so every * requested tool name is preserved verbatim. Lifecycle tools (`caller_ping`, * `subagent_done`) are always reserved whenever we emit a restrictive list so * block/done signaling still works. Coordinator tools are not auto-reserved: * a coordinator agent must list them in its own `tools:` declaration. */ export function resolvePiToolsArg(effectiveTools: string | undefined): string | undefined { if (!effectiveTools) return undefined; const requested = effectiveTools .split(",") .map((t) => t.trim()) .filter(Boolean); if (requested.length === 0) return undefined; const merged = new Set([...requested, ...PI_LIFECYCLE_TOOLS]); return [...merged].join(","); } // ── Helpers ──────────────────────────────────────────────────────────────── /** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */ export function getAgentConfigDir(): string { return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); } function getBundledAgentsDir(): string { return join(dirname(new URL(import.meta.url).pathname), "../../agents"); } function getFrontmatterValue(frontmatter: string, key: string): string | undefined { const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m")); return match ? match[1].trim() : undefined; } function parseOptionalBoolean(value: string | undefined): boolean | undefined { return value != null ? value === "true" : undefined; } function parseSessionMode(value: string | undefined): SubagentSessionMode | undefined { if (value === "standalone" || value === "lineage-only" || value === "fork") { return value; } return undefined; } function parseAgentDefaultsFromContent(content: string): AgentDefaults | null { const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return null; const frontmatter = match[1]; const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim(); const systemPromptMode = getFrontmatterValue(frontmatter, "system-prompt"); return { model: getFrontmatterValue(frontmatter, "model"), tools: getFrontmatterValue(frontmatter, "tools"), systemPromptMode: systemPromptMode === "replace" ? "replace" : systemPromptMode === "append" ? "append" : undefined, skills: getFrontmatterValue(frontmatter, "skill") ?? getFrontmatterValue(frontmatter, "skills"), thinking: getFrontmatterValue(frontmatter, "thinking"), denyTools: getFrontmatterValue(frontmatter, "deny-tools"), spawning: parseOptionalBoolean(getFrontmatterValue(frontmatter, "spawning")), autoExit: parseOptionalBoolean(getFrontmatterValue(frontmatter, "auto-exit")), interactive: parseOptionalBoolean(getFrontmatterValue(frontmatter, "interactive")), sessionMode: parseSessionMode(getFrontmatterValue(frontmatter, "session-mode")), cwd: getFrontmatterValue(frontmatter, "cwd"), cli: getFrontmatterValue(frontmatter, "cli"), executionPolicy: parseExecutionPolicy(getFrontmatterValue(frontmatter, "execution-policy")), body: body || undefined, disableModelInvocation: getFrontmatterValue(frontmatter, "disable-model-invocation")?.toLowerCase() === "true", }; } /** * Load agent defaults by name. Default search order matches the legacy * behavior: project-local `.pi/agents/`, then `$PI_CODING_AGENT_DIR/agents/` * (or `~/.pi/agent/agents/`), then bundled. `projectRoot` overrides the root * used for the project-local search (defaults to `process.cwd()`); pass the * target child's cwd when launching into another repo so target-local agents * win over parent-session agents. If `searchDirs` is provided, each directory * is tried in order for `/.md` and the bundled / default * paths are skipped — this is the escape hatch tests use to point at * deterministic agent fixtures. * * Accepts a legacy positional `searchDirs: string[]` or the new options-bag * form `{ searchDirs?, projectRoot?, projectTrusted? }`. * * `projectTrusted` gates the project-local `.pi/agents/` lookup: when it is * explicitly `false`, that path is dropped so an untrusted repository cannot * supply agent frontmatter (model, tools, execution-policy, cwd, …) that would * influence launch behavior before project trust is established. When omitted * the project-local path is searched as before (trusted by default), preserving * legacy behavior for callers that do not observe project trust. The explicit * `searchDirs` escape hatch is never gated — it points at deterministic * fixtures owned by the caller, not at repo-supplied config. */ export function loadAgentDefaults( agentName: string, searchDirsOrOptions?: | string[] | { searchDirs?: string[]; projectRoot?: string; projectTrusted?: boolean }, ): AgentDefaults | null { let searchDirs: string[] | undefined; let projectRoot: string | undefined; let projectTrusted = true; if (Array.isArray(searchDirsOrOptions)) { searchDirs = searchDirsOrOptions; } else if (searchDirsOrOptions) { searchDirs = searchDirsOrOptions.searchDirs; projectRoot = searchDirsOrOptions.projectRoot; if (searchDirsOrOptions.projectTrusted === false) projectTrusted = false; } const paths: string[] = []; if (searchDirs && searchDirs.length > 0) { for (const d of searchDirs) { paths.push(join(d, `${agentName}.md`)); } } else { const configDir = getAgentConfigDir(); const effectiveProjectRoot = projectRoot ?? process.cwd(); // Skip the project-local `.pi/agents/` candidate for untrusted projects so // repo-supplied frontmatter cannot bootstrap launch behavior before trust. if (projectTrusted) { paths.push(join(effectiveProjectRoot, ".pi", "agents", `${agentName}.md`)); } paths.push( join(configDir, "agents", `${agentName}.md`), join(getBundledAgentsDir(), `${agentName}.md`), ); } for (const p of paths) { if (!existsSync(p)) continue; const parsed = parseAgentDefaultsFromContent(readFileSync(p, "utf8")); if (parsed) return parsed; } return null; } /** * Resolve the effective set of denied tool names from agent defaults. * `spawning: false` expands to all SPAWNING_TOOLS. * `deny-tools` adds individual tool names on top. */ export function resolveDenyTools(agentDefs: AgentDefaults | null): Set { const denied = new Set(); if (!agentDefs) return denied; if (agentDefs.spawning === false) { for (const t of SPAWNING_TOOLS) denied.add(t); } if (agentDefs.denyTools) { for (const t of agentDefs.denyTools .split(",") .map((s) => s.trim()) .filter(Boolean)) { denied.add(t); } } return denied; } export function resolveSubagentPaths( params: SubagentParamsType, agentDefs: AgentDefaults | null, sessionCwd?: string, ): { effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string } { const rawCwd = params.cwd ?? agentDefs?.cwd ?? null; const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null; // Relative caller-supplied cwd resolves against the session cwd, not the // Node process cwd, so agent lookup and session placement stay in the same // tree. Agent-frontmatter `cwd` stays anchored to the global config dir. const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : (sessionCwd ?? process.cwd()); const effectiveCwd = rawCwd ? rawCwd.startsWith("/") ? rawCwd : join(cwdBase, rawCwd) : null; const localAgentDir = effectiveCwd ? join(effectiveCwd, ".pi", "agent") : null; const effectiveAgentDir = localAgentDir && existsSync(localAgentDir) ? localAgentDir : getAgentConfigDir(); return { effectiveCwd, localAgentDir, effectiveAgentDir }; } export function getDefaultSessionDirFor(cwd: string, agentDir: string): string { const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; const sessionDir = join(agentDir, "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } return sessionDir; } export function resolveEffectiveSessionMode( params: SubagentParamsType, agentDefs: AgentDefaults | null, ): SubagentSessionMode { if (params.fork) return "fork"; return agentDefs?.sessionMode ?? "standalone"; } export function resolveLaunchBehavior( params: SubagentParamsType, agentDefs: AgentDefaults | null, ): { sessionMode: SubagentSessionMode; seededSessionMode: "lineage-only" | "fork" | null; inheritsConversationContext: boolean; taskDelivery: "direct" | "artifact"; } { const sessionMode = resolveEffectiveSessionMode(params, agentDefs); const inheritsConversationContext = sessionMode === "fork"; return { sessionMode, seededSessionMode: sessionMode === "standalone" ? null : sessionMode, inheritsConversationContext, taskDelivery: inheritsConversationContext ? "direct" : "artifact", }; } export function resolveEffectiveInteractive( params: SubagentParamsType, agentDefs: AgentDefaults | null, ): boolean { if (params.interactive != null) return params.interactive; if (agentDefs?.interactive != null) return agentDefs.interactive; return !(agentDefs?.autoExit ?? false); } /** * Build the internal artifact directory path for the current session. * /artifacts// */ export function getArtifactDir(sessionDir: string, sessionId: string): string { return join(sessionDir, "artifacts", sessionId); } /** * Build the positional prompt args for a Pi CLI subagent launch. * * In artifact-backed launches (lineage-only, standalone), Pi's buildInitialMessage() * concatenates @file content with messages[0] into one initial prompt. That breaks * /skill: expansion because the message no longer starts with "/skill:". Only * messages[1..] are sent as separate follow-up prompts where /skill: is recognized. * * When there are skill prompts AND artifact-backed delivery, we prepend an empty * first positional message so that /skill: args land in messages[1..] and arrive * as standalone prompts in the child session. */ export function buildPiPromptArgs(params: { effectiveSkills?: string; taskDelivery: "direct" | "artifact"; taskArg: string; }): string[] { const skillPrompts = (params.effectiveSkills ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean) .map((skill) => `/skill:${skill}`); const needsSeparator = params.taskDelivery === "artifact" && skillPrompts.length > 0; return [ ...(needsSeparator ? [""] : []), ...skillPrompts, params.taskArg, ]; } /** * Strip a leading `/` prefix from a model string for Claude CLI. * "anthropic/claude-haiku-4-5" → "claude-haiku-4-5" * "claude-sonnet-4-6" → "claude-sonnet-4-6" (no prefix) * * Rule: split on first `/`; if the left segment looks like a simple provider * identifier (letters/digits/dashes only), drop it. Otherwise keep as-is. */ function projectModelForClaude(model: string | undefined): string | undefined { if (!model) return undefined; const slash = model.indexOf("/"); if (slash <= 0) return model; const left = model.slice(0, slash); const right = model.slice(slash + 1); if (/^[a-zA-Z0-9-]+$/.test(left) && right.length > 0) return right; return model; } function safeFileName(raw: string, fallback: string): string { return ( raw .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") || fallback ); } // ── Main entry point ─────────────────────────────────────────────────────── /** * Minimal `ctx` shape required by resolveLaunchSpec. Real callers pass the * full `ExtensionContext["sessionManager"]` (which satisfies this shape); this * loose typing lets tests and the launchSubagent wrapper pass minimal stubs * without pulling in the whole ReadonlySessionManager surface. */ export interface LaunchSpecContext { sessionManager: { getSessionId(): string; getSessionDir(): string; getSessionFile?(): string | null; }; cwd: string; /** * Effective project-trust decision for the parent session, as reported by Pi * `ctx.isProjectTrusted()` (available since Pi 0.79.1). Optional so tests and * older callers that do not observe project trust keep the legacy behavior of * treating the project as trusted. When this returns `false`, project-local * `.pi/agents/` discovery is skipped (see `loadAgentDefaults`). */ isProjectTrusted?: () => boolean; } /** * Per-launch Pi project-trust override for a child process the framework starts * on the user's behalf. Pi 0.79.0 added `--approve` / `--no-approve` one-run * controls over project trust; `--approve` approves project-local input loading * (settings, resources, packages, extensions, skills) for THIS run only and does * NOT cause pi-mux-subagents to write the user's persistent trust state. We pass * it on both the pane and headless Pi launch paths so subagents do not stall on * the interactive project-trust prompt and do not silently skip project-local * resources merely because they run as subagents — mirroring how Codex launches * receive a per-launch trusted-project override and Claude launches bypass the * workspace-trust dialog. This is a launch-time input-loading decision and is * deliberately separate from `executionPolicy` (backend autonomy/sandboxing). * * Returns RAW tokens (no shell escaping); pane callers shell-escape each token. */ export function buildPiProjectTrustArgs(): string[] { return ["--approve"]; } function validateSpawningToolsConflict( agentDefs: AgentDefaults | null, effectiveTools: string | undefined, ): void { if (agentDefs?.spawning !== false) return; if (!effectiveTools) return; const conflicting = effectiveTools .split(",") .map((t) => t.trim()) .filter((t) => SPAWNING_TOOLS.has(t)); if (conflicting.length === 0) return; throw new Error( `Agent declares \`spawning: false\` but \`tools:\` includes orchestration tool(s): ${conflicting.join(", ")}. ` + `Remove the conflicting token(s) from \`tools:\` or remove \`spawning: false\` so the coordinator can dispatch children. ` + `Note: pi orchestration tools are only available to pi-backed coordinator agents (\`cli: pi\`); the Claude CLI does not expose them.`, ); } export function resolveLaunchSpec( params: SubagentParamsType, ctx: LaunchSpecContext, opts?: { agentSearchDirs?: string[] }, ): ResolvedLaunchSpec { const id = Math.random().toString(16).slice(2, 10); // Pre-resolve the target child cwd from params.cwd only (agent frontmatter // `cwd` can't participate here — agent lookup precedes agentDefs). This // ensures agent discovery follows the caller-specified target project's // local `.pi/agents/` rather than the parent session's cwd when launching // into another repo or worktree. const preResolvedTargetCwd = params.cwd ? params.cwd.startsWith("/") ? params.cwd : join(ctx.cwd, params.cwd) : null; // Gate project-local `.pi/agents/` discovery on the parent session's // effective project-trust decision. Absent `isProjectTrusted` (older runtime // or test stubs) we keep the legacy trusted-by-default behavior. const projectTrusted = ctx.isProjectTrusted ? ctx.isProjectTrusted() : true; const agentDefs = params.agent ? loadAgentDefaults(params.agent, { searchDirs: opts?.agentSearchDirs, projectRoot: preResolvedTargetCwd ?? ctx.cwd, projectTrusted, }) : null; const effectiveModel = params.model ?? agentDefs?.model; const effectiveTools = params.tools ?? agentDefs?.tools; validateSpawningToolsConflict(agentDefs, effectiveTools); const effectiveSkills = params.skills ?? agentDefs?.skills; const effectiveThinking = params.thinking ?? agentDefs?.thinking; const effectiveCli = params.cli ?? agentDefs?.cli ?? "pi"; const claudeModelArg = projectModelForClaude(effectiveModel); const codexModelArg = projectModelForClaude(effectiveModel); // Execution policy: params override agent frontmatter override the safe // default. `executionPolicySource` records which input won so callers/tests // can reason about default vs explicit requests. const paramsPolicy = parseExecutionPolicy(params.executionPolicy); const agentPolicy = agentDefs?.executionPolicy; const effectiveExecutionPolicy: ExecutionPolicy = paramsPolicy ?? agentPolicy ?? DEFAULT_EXECUTION_POLICY; const executionPolicySource: ExecutionPolicySource = paramsPolicy ? "params" : agentPolicy ? "agent" : "default"; const sessionId = ctx.sessionManager.getSessionId(); const sessionDirBase = ctx.sessionManager.getSessionDir(); const artifactDir = getArtifactDir(sessionDirBase, sessionId); const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths( params, agentDefs, ctx.cwd, ); const targetCwdForSession = effectiveCwd ?? ctx.cwd; const sessionDir = getDefaultSessionDirFor(targetCwdForSession, effectiveAgentDir); // Deterministic session-file path — eliminates launch-time races between // multiple parallel agents by giving each one a uuid-tagged jsonl file. const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23) + "Z"; const uuid = [ id, Math.random().toString(16).slice(2, 10), Math.random().toString(16).slice(2, 10), Math.random().toString(16).slice(2, 6), ].join("-"); const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`); const launchBehavior = resolveLaunchBehavior(params, agentDefs); const { sessionMode, seededSessionMode, inheritsConversationContext, taskDelivery } = launchBehavior; const denySet = resolveDenyTools(agentDefs); // Task-wrapping identity + modeHint + summaryInstruction (shared with pane path). const modeHint = agentDefs?.autoExit ? "Complete your task autonomously." : "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time."; const summaryInstruction = agentDefs?.autoExit ? "Your FINAL assistant message should summarize what you accomplished." : "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished."; // Compose identity from the trimmed agent body and per-call systemPrompt. // When both are present, the agent body comes first, then a blank line, then // the caller prompt. Empty/whitespace-only sources are ignored. const trimmedBody = agentDefs?.body?.trim() || null; const trimmedSystemPrompt = params.systemPrompt?.trim() || null; const identity = trimmedBody && trimmedSystemPrompt ? `${trimmedBody}\n\n${trimmedSystemPrompt}` : (trimmedBody ?? trimmedSystemPrompt ?? null); const systemPromptMode = agentDefs?.systemPromptMode; // Finite pane-backend discriminator (ties effectiveCli → the completion seam). // Unknown / headless-only CLIs fall through to "pi", matching the legacy pane // dispatch (anything not "claude"/"codex" launched as pi). A genuinely new // pane backend is added by extending PaneBackend (which breaks the build until // a variant + dispatch branch exist), never via an ad-hoc effectiveCli string. const paneBackend: PaneBackend = effectiveCli === "claude" ? "claude" : effectiveCli === "codex" ? "codex" : "pi"; // Identity delivery is a first-class, per-backend capability (see // identity-delivery.ts), parallel to the completion-protocol seam. Whether the // composed identity is routed through a backend's system-prompt channel (a CLI // flag, out-of-band from the body) or carried in the task body derives from // the backend's declared policy — NOT from the bare `systemPromptMode`. Codex // has no system-prompt channel, so its identity always rides the task body; // Claude always routes to its flag; pi is hybrid (flag when a mode is set, // else the role block). This is the fix for Codex silently dropping identity // whenever an agent declared `system-prompt: append|replace`. const identityInSystemPrompt = !!identity && identityRoutesToSystemPrompt(resolveIdentityDelivery(paneBackend), systemPromptMode); const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : ""; const fullTask = inheritsConversationContext ? params.task : `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`; // Claude-only task body: identity reaches Claude via --system-prompt / // --append-system-prompt, never via the task body. So omit `roleBlock`. const claudeTaskBody = inheritsConversationContext ? params.task : `${modeHint}\n\n${params.task}\n\n${summaryInstruction}`; // CLI-neutral core (identity + task, no completion wording). Mirrors // fullTask's roleBlock handling so Codex identity delivery is unchanged. const neutralCore = inheritsConversationContext ? params.task : `${roleBlock}\n\n${params.task}`; const skillPrompts = (effectiveSkills ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean) .map((skill) => `/skill:${skill}`); const configRootEnv: Record = {}; if (localAgentDir && existsSync(localAgentDir)) { configRootEnv.PI_CODING_AGENT_DIR = localAgentDir; } else if (process.env.PI_CODING_AGENT_DIR) { configRootEnv.PI_CODING_AGENT_DIR = process.env.PI_CODING_AGENT_DIR; } const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs); return { name: params.name, task: params.task, agent: params.agent, effectiveCli, effectiveModel, claudeModelArg, codexModelArg, effectiveTools, effectiveSkills, effectiveThinking, skillPrompts, effectiveCwd, localAgentDir, effectiveAgentDir, configRootEnv, identity, identityInSystemPrompt, systemPromptMode, fullTask, claudeTaskBody, paneBackend, neutralCore, sessionMode, seededSessionMode, inheritsConversationContext, taskDelivery, subagentSessionFile, artifactDir, autoExit: agentDefs?.autoExit === true, effectiveInteractive, claudeCompletionAddendum: paneBackend === "claude" ? composePanePrompt({ neutralCore: claudeTaskBody, protocol: resolvePaneCompletionProtocol( "claude", agentDefs?.autoExit === true ? "autonomous" : "interactive", ), }).systemPromptCompletion : null, denySet, resumeSessionId: params.resumeSessionId, focus: params.focus, effectiveExecutionPolicy, executionPolicySource, agentDefs, }; } /** * Warn (once per launch) when a guarded execution policy is requested for a * backend that has no implemented guarded mode. Only Claude implements guarded * execution today; pi (and future OpenCode) currently run unrestricted. We warn * and continue with documented current behavior rather than rejecting the * launch. * * The warning fires only when guarded was *explicitly* requested (agent * frontmatter or params), never for the implicit `guarded` default, so routine * pi launches stay quiet. */ export function warnGuardedPolicyUnsupported( spec: Pick, context?: DiagnosticContext, ): void { if (spec.effectiveCli === "claude" || spec.effectiveCli === "codex") return; if (spec.effectiveExecutionPolicy !== "guarded") return; if (spec.executionPolicySource === "default") return; emitDiagnostic({ code: "guarded-policy-unsupported", audience: { human: true, structured: true }, message: `[subagents] ${spec.name}: guarded execution unsupported by ${spec.effectiveCli}; ` + `using unrestricted mode\n`, }, context); } // ── Artifact-write helpers (side-effectful) ──────────────────────────────── /** * Write the system-prompt artifact file used when `identityInSystemPrompt` * is true (agent frontmatter sets `system-prompt: append|replace`). Returns * the path the pane command should reference via `--append-system-prompt` * / `--system-prompt`. Returns `null` when not applicable. */ export function writeSystemPromptArtifact( spec: ResolvedLaunchSpec, namePrefix?: string, ): string | null { if (!spec.identityInSystemPrompt || !spec.identity) return null; const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const spSafeName = safeFileName(namePrefix ?? spec.name ?? "subagent", "subagent"); const syspromptPath = join( spec.artifactDir, `context/${spSafeName}-sysprompt-${spTimestamp}.md`, ); mkdirSync(dirname(syspromptPath), { recursive: true }); writeFileSync(syspromptPath, spec.identity, "utf8"); return syspromptPath; } /** * Write the task artifact consumed by pi via `@file` substitution when * `taskDelivery === "artifact"`. Returns the artifact path. For direct * delivery the caller uses `spec.fullTask` directly. */ export function writeTaskArtifact(spec: ResolvedLaunchSpec, namePrefix?: string): string { const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const safeName = safeFileName(namePrefix ?? spec.name, "subagent"); const artifactName = `context/${safeName}-${timestamp}.md`; const artifactPath = join(spec.artifactDir, artifactName); mkdirSync(dirname(artifactPath), { recursive: true }); writeFileSync(artifactPath, spec.fullTask, "utf8"); return artifactPath; }