/** * The `subagent` tool: dispatches enabled built-in and custom roles as * isolated pi child processes, single or parallel. * Owns the public dispatch contract and * per-run status tracking. Stable thread generations, final integration, and * completion ownership live in thread-lifecycle.ts. */ import { StringEnum } from "@earendil-works/pi-ai"; import { resolve } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { discoverAgents, isWriteCapableAgent, type AgentConfig } from "./agents.ts"; import { loadConfig } from "../configuration/config.ts"; import { resolveSubagentConcurrency } from "../execution/background.ts"; import { formatCompletionBlock, formatUsage } from "../presentation/format.ts"; import { formatTaskSummary, formatToolActivity, monitor, statusIcon, statusLabel, type RunWaitReason, } from "../presentation/monitor.ts"; import { findDuplicateDispatch, formatParallelScopeAdmissionNote, formatPhaseLeaseReceipt } from "./prompt.ts"; import { findActiveWriterLease, findPhaseScopeOverlap, findWriterLeaseScopeOverlap, normalizePhaseId, normalizePhaseScope, PHASE_ID_MAX_LENGTH, PHASE_ID_PATTERN_SOURCE, type PhaseScope, type PhaseScopeInput, } from "./phase-scope.ts"; import type { SubagentRuntime, SubagentThread } from "../lifecycle/runtime.ts"; import { createBackgroundDispatcher } from "../lifecycle/thread-lifecycle.ts"; import { getResultError, getResultOutput, isFailedResult, type SingleResult, type SubagentDetails, type SubagentLiveEvent, type UsageStats, } from "../execution/spawn.ts"; import { isWorktreeCapableAgent, persistThreadCheckpoint, projectResultsRoot, runInManagedRepositoryLane, type DispatchEnvironment, } from "../lifecycle/thread-shared.ts"; import type { IsolationMode } from "../isolation/worktree.ts"; export { isWorktreeCapableAgent, runInManagedRepositoryLane }; const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const; const ISOLATION_DESCRIPTION = "Git isolation (not a sandbox): shared uses the caller's checkout; worktree creates a detached temporary worktree for write-capable agents only."; const IsolationSchema = Type.Optional( StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }), ); const PhaseIdSchema = Type.Optional(Type.String({ minLength: 1, maxLength: PHASE_ID_MAX_LENGTH, pattern: PHASE_ID_PATTERN_SOURCE, description: "Stable logical phase id. Reuse it when rewording the same phase; exact task+cwd is the fallback when omitted.", })); const ScopeSchema = Type.Optional(Type.Object({ paths: Type.Optional(Type.Array(Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact file or directory write claim resolved from the caller-facing cwd; wildcard * and ? are rejected, while other punctuation is literal.", }))), symbols: Type.Optional(Type.Array(Type.Object({ path: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact file path resolved from the caller-facing cwd." }), name: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact symbol name claimed for writing." }), }))), }, { description: "Declarative write-conflict metadata for admission, not filesystem permissions or a sandbox. If present, at least one valid claim is required." })); const WaitSchema = Type.Optional( Type.Boolean({ description: "Block until every run started by this call settles, then return each result exactly once in this tool response. If the tool call is aborted, undelivered results fall back to completion messages. Intended for one-shot (pi -p) sessions or an immediate dependent step.", }), ); const TASK_BRIEF_DESCRIPTION = "Complete brief: objective and done condition, relevant paths/symbols, known facts with citations when available, boundaries, and needed output. The child has no parent conversation."; const TaskItem = Type.Object({ agent: Type.String({ description: "Name of the agent to invoke" }), task: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: TASK_BRIEF_DESCRIPTION, }), phaseId: PhaseIdSchema, scope: ScopeSchema, cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), isolation: IsolationSchema, }); const SubagentParams = Type.Object({ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })), task: Type.Optional( Type.String({ ...NON_BLANK_TASK_OPTIONS, description: `${TASK_BRIEF_DESCRIPTION} (single mode)` }), ), phaseId: PhaseIdSchema, scope: ScopeSchema, tasks: Type.Optional(Type.Array(TaskItem, { description: "Independently justified, disjoint phases for parallel execution" })), cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })), isolation: IsolationSchema, wait: WaitSchema, }); /** Roles that default to worktree isolation in parallel dispatches even when * the live catalog cannot be consulted (render-only call sites). Custom * worktree-capable agents join them via the live catalog on the execute path. */ const WORKTREE_DEFAULT_AGENTS = new Set(["artisan", "steward"]); /** Resolve the default isolation for a dispatch. Precedence: an explicit * per-call request, then the role's own frontmatter declaration (`worktree` * honored for write-capable roles only; `shared` always), then the parallel * write default — parallel write-capable agents get a detached worktree * because shared writers serialize on the repository lane, so defaulting them * to shared would turn one parallel batch into a convoy that also parks * process slots. */ export function defaultIsolationMode( mode: "single" | "parallel", agentName: string, requested?: IsolationMode, writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName), declared?: IsolationMode, ): IsolationMode { if (requested) return requested; if (declared === "shared") return "shared"; if (declared === "worktree" && writeCapable) return "worktree"; return mode === "parallel" && writeCapable ? "worktree" : "shared"; } interface PreparedDispatchTask { index: number; agent: string; task: string; cwd: string; phaseId?: string; scope?: PhaseScope; isolation?: IsolationMode; writeCapable: boolean; } function prepareDispatchTasks( tasks: ReadonlyArray<{ agent: string; task: string; cwd?: string; phaseId?: string; scope?: PhaseScopeInput; isolation?: IsolationMode }>, callerCwd: string, agents: readonly AgentConfig[], ): PreparedDispatchTask[] { return tasks.map((item, index) => { const cwd = resolve(callerCwd, item.cwd ?? "."); const agent = agents.find((candidate) => candidate.name === item.agent); return { index, agent: item.agent, task: item.task, cwd, phaseId: normalizePhaseId(item.phaseId), scope: normalizePhaseScope(item.scope, cwd), isolation: item.isolation, writeCapable: agent ? isWriteCapableAgent(agent) : true, }; }); } function parallelAdmissionConflict( tasks: readonly PreparedDispatchTask[], threads: Iterable, ): string | undefined { for (let leftIndex = 0; leftIndex < tasks.length; leftIndex++) { for (let rightIndex = leftIndex + 1; rightIndex < tasks.length; rightIndex++) { const left = tasks[leftIndex]!; const right = tasks[rightIndex]!; const duplicate = findDuplicateDispatch([{ id: left.index, agentName: left.agent, task: left.task, phaseId: left.phaseId, cwd: left.cwd, state: "queued", }], right.task, right.cwd, right.phaseId); if (duplicate) { return `deterministic duplicate between tasks[${left.index}] and tasks[${right.index}]`; } } } const sentinelTask = tasks.find((task) => task.agent === "sentinel"); if (sentinelTask) { const batchWriter = tasks.find( (task) => task !== sentinelTask && task.agent !== "sentinel" && task.writeCapable, ); if (batchWriter) { return `tasks[${sentinelTask.index}] (sentinel) reviews a completed diff, but tasks[${batchWriter.index}] (${batchWriter.agent}) writes in the same batch; review follows the writer's completion`; } } const leases = [...threads]; if (sentinelTask) { const activeWriter = findActiveWriterLease(leases); if (activeWriter) { const state = activeWriter.lifecycleOperation === "settle" ? "settling" : activeWriter.state; return `tasks[${sentinelTask.index}] (sentinel) reviews a completed diff, but run #${activeWriter.id} (${activeWriter.agentName}, ${state}) is still writing`; } } for (const task of tasks) { const duplicate = findDuplicateDispatch(leases, task.task, task.cwd, task.phaseId); if (duplicate?.kind === "active") { return `tasks[${task.index}] duplicates active run #${duplicate.source.id} (${duplicate.source.agentName})`; } if (duplicate?.kind === "settled") { return `tasks[${task.index}] duplicates settled run #${duplicate.source.id} (${duplicate.source.agentName}); inspect it with subagent_status and handle follow-up work in main`; } } const writers = tasks.filter( (task): task is PreparedDispatchTask & { scope: PhaseScope } => task.writeCapable && task.scope !== undefined, ); for (let leftIndex = 0; leftIndex < writers.length; leftIndex++) { for (let rightIndex = leftIndex + 1; rightIndex < writers.length; rightIndex++) { const left = writers[leftIndex]!; const right = writers[rightIndex]!; const overlap = findPhaseScopeOverlap(left.scope, right.scope); if (overlap) { return `tasks[${left.index}] scope ${overlap.left} overlaps tasks[${right.index}] scope ${overlap.right}`; } } } for (const task of writers) { const conflict = findWriterLeaseScopeOverlap(task.scope, leases); if (conflict) { return `tasks[${task.index}] scope ${conflict.overlap.left} overlaps run #${conflict.lease.id} scope ${conflict.overlap.right}`; } } return undefined; } /** Awaited children report their usage per run and per model in the result * blocks below; nothing is attached to the tool result itself, because pi * folds tool-result usage into one session total — that merged every * model's spend into the main window's consumption line. */ /** In-turn wait for a fresh dispatch. Registration resolves it without a model-chosen * timer; parent abort or removal ends the wait without losing background delivery. */ export async function awaitRunResults( runtime: SubagentRuntime, runIds: number[], signal: AbortSignal | undefined, maxResultLines: number, fallbackCwd: string, onProgress?: (text: string) => void, ): Promise { const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => { const already = runtime.settledRuns.get(runId); if (already) return Promise.resolve({ result: already }); if (monitor.findRun(runId)?.status === "parked") { return Promise.resolve({ note: `run #${runId} was interrupted; inspect retained work with subagent_status and finish it in main` }); } return new Promise((resolve) => { let done = false; let unsub: (() => void) | undefined; const cleanup = (): void => { if (unsub) unsub(); signal?.removeEventListener("abort", onAbort); const listeners = runtime.settledListeners.get(runId); if (listeners) { listeners.delete(onSettled); if (listeners.size === 0) runtime.settledListeners.delete(runId); } }; const finish = (outcome: { result?: SingleResult; note?: string }): void => { if (done) return; done = true; cleanup(); resolve(outcome); }; const onSettled = (result: SingleResult): void => finish({ result }); const onMonitor = (): void => { const current = runtime.settledRuns.get(runId); if (current) { finish({ result: current }); return; } const live = monitor.findRun(runId); if (live?.status === "parked") { finish({ note: `run #${runId} was interrupted; inspect retained work with subagent_status and finish it in main` }); return; } if (!live) { // Removal is followed synchronously by registerRunResult in the // finishing task; re-check on the next tick so the result wins. setTimeout(() => { const late = runtime.settledRuns.get(runId); if (late) finish({ result: late }); else finish({ note: `run #${runId} was removed before its result was recorded (cancelled or session ended)` }); }, 0); } }; const onAbort = (): void => finish({ note: "wait aborted" }); let listeners = runtime.settledListeners.get(runId); if (!listeners) { listeners = new Set(); runtime.settledListeners.set(runId, listeners); } listeners.add(onSettled); unsub = monitor.subscribe(onMonitor); if (signal?.aborted) onAbort(); else signal?.addEventListener("abort", onAbort, { once: true }); }); }; // One shared subscription drives the progress line: each waiter already // subscribes for its own settlement, and the tool card wants a single // rolled-up line rather than one per run. let lastProgress: string | undefined; const emitProgress = onProgress ? (): void => { const parts = runIds.map((id) => { const settled = runtime.settledRuns.get(id); if (settled) return `#${id} ${isFailedResult(settled) ? "failed" : "done"}`; const live = monitor.findRun(id); return live ? `#${id} ${statusLabel(live.status)}` : `#${id} …`; }); const text = `Waiting in-turn on ${runIds.length} run${runIds.length === 1 ? "" : "s"} · ${parts.join(", ")}`; // The monitor notifies on every usage and activity change; this line // names only statuses, so most notifications leave it identical. if (text === lastProgress) return; lastProgress = text; onProgress(text); } : undefined; const progressUnsub = emitProgress ? monitor.subscribe(emitProgress) : undefined; emitProgress?.(); try { const outcomes = await Promise.all(runIds.map(waitForRun)); return outcomes.map((outcome) => outcome.result ? formatCompletionBlock(outcome.result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? fallbackCwd) }) : (outcome.note ?? "(no outcome)"), ).join("\n\n"); } finally { progressUnsub?.(); } } export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void { // Each dispatch refreshes the context, config, and agent catalog. const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined }; // Terminal rows stay in the monitor until the next beginTurn so the footer // can count them beside siblings that are still live. The widget ignores // them. Repeated publication of the same settlement is a no-op. const publishedEndedAt = new Map(); const finishRun = ( runId: number, status: "done" | "failed", opts?: { silent?: boolean }, ): void => { const run = monitor.findRun(runId); if (!run) return; monitor.setStatus(runId, status); // stamps endedAt for the elapsed time const endedAt = monitor.findRun(runId)?.endedAt; if (endedAt !== undefined && publishedEndedAt.get(runId) === endedAt) return; if (endedAt !== undefined) publishedEndedAt.set(runId, endedAt); if (opts?.silent || !runtime.sessionActive) return; const icon = status === "done" ? "✓" : "✗"; const result = runtime.threads.get(runId)?.lastResult; const error = status === "failed" ? result ? getResultError(result) : "No failure reason was recorded." : undefined; environmentRef.current?.ctx.ui.notify( `${icon} #${run.id} ${monitor.summarize(run)}${error ? ` · ${formatTaskSummary(error, 300, false)}` : ""}`, status === "done" ? "info" : "error", ); }; // Live sub-agent activity → concise one-line status ("thinking", // "read src/index.ts", ...), never a raw args blob. The live handler // only updates monitor state; the queue task owns terminal removal, // notification, and lifecycle decisions. const makeLiveHandler = (runId: number, generation?: number) => (e: SubagentLiveEvent): void => { if (generation !== undefined && runtime.threads.get(runId)?.generation !== generation) return; switch (e.kind) { case "status": monitor.setStatus(runId, e.status); // A fresh running segment refreshes the durable checkpoint (session // path plus child pids) so a crash mid-generation still restores. if (e.status === "running") { const thread = runtime.threads.get(runId); if (thread?.sessionId && thread.sessionDir) { persistThreadCheckpoint(runtime, thread, "parked"); } } break; case "model": monitor.setModel(runId, e.model, e.fallbackFrom); monitor.setThinking(runId, e.thinking); break; case "usage": monitor.setUsage(runId, e.usage, e.model); break; case "session": { runtime.retainSession({ sessionDir: e.sessionDir }); const thread = runtime.threads.get(runId); if (thread && (generation === undefined || runtime.threads.get(runId)?.generation === generation)) { thread.sessionId = e.sessionId; thread.sessionDir = e.sessionDir; persistThreadCheckpoint(runtime, thread, "parked"); } break; } case "tool_start": monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args)); break; case "tool_end": monitor.recordToolEnd(runId, e.toolName, e.isError); break; case "thinking": monitor.setActivity(runId, "thinking"); break; case "text": // A text delta is model output, not a filesystem write. monitor.setActivity(runId, "responding"); break; } }; const makeDetails = (mode: "single" | "parallel", background = false) => (results: SingleResult[]): SubagentDetails => ({ mode, results, background }); const phaseLeaseReceipt = ( runIds: number[], options: { mode: "single" } | { mode: "parallel"; declaredScopesComplete: boolean }, ): string => formatPhaseLeaseReceipt( runIds .map((runId) => runtime.threads.get(runId)) .filter((thread): thread is SubagentThread => thread !== undefined), options, ); /** Pacing note appended to dispatch confirmations whenever runs are actually * waiting. Slot waits and repository-lane waits are stated separately with * the real capacity: a lane-serialized shared writer or a starting child * must never read as an exhausted pool. Empty when nothing is waiting. */ const queuePacingNote = (): string => { const runs = monitor.getRuns(); const queuedWith = (reason: RunWaitReason): number => runs.filter((run) => run.status === "queued" && run.waitReason === reason).length; const slotWaiting = queuedWith("process-slot"); const laneWaiting = queuedWith("repository-lane"); if (slotWaiting === 0 && laneWaiting === 0) return ""; const executing = runs.filter((run) => run.status === "running" || run.status === "interrupting" || (run.status === "queued" && run.waitReason === "starting"), ).length; const capacity = runtime.backgroundQueue.capacity; const freeSlots = Math.max(0, capacity - runtime.backgroundQueue.activeCount); const parts = [`${executing} running`]; if (slotWaiting > 0) { parts.push(`${slotWaiting} waiting for a free process slot (capacity ${capacity}); they start automatically as slots free`); } if (laneWaiting > 0) { parts.push( `${laneWaiting} shared-checkout writer${laneWaiting === 1 ? "" : "s"} waiting for the repository write lane — write serialization, not slot capacity` + (slotWaiting === 0 ? ` (${freeSlots} of ${capacity} slots free; parallel writers avoid the lane via worktree isolation)` : ""), ); } return ` Pacing: ${parts.join(" · ")}.`; }; const startBackground = createBackgroundDispatcher({ runtime, getEnvironment: () => { if (!environmentRef.current) { throw new Error("pi-subagents dispatch environment is not ready yet."); } return environmentRef.current; }, finishRun, makeLiveHandler, makeDetails, }); pi.registerTool({ name: "subagent", label: "Subagent", description: "Start one-shot leaf runs for substantial work. Duplicate phases and declared writer overlaps are rejected before allocation; scope does not prove independence or grant permissions. Parallel tasks without scope report `independence not verified`. Results arrive automatically, or in-turn with wait:true. Main handles incomplete work.", parameters: SubagentParams, async execute(_toolCallId, params, signal, onUpdate, ctx) { // `wait: true` holds this call for minutes and would otherwise show a // blank card; the background path returns at once and has nothing to // stream. Frames carry the final details shape because renderResult // falls back to "(no output)" without it. const makeProgress = (details: SubagentDetails): ((text: string) => void) | undefined => onUpdate ? (text: string): void => onUpdate({ content: [{ type: "text", text }], details }) : undefined; // Run ids are allocated below; restore raises the allocator above every // id a durable record still owns, so a dispatch racing it could hand a // fresh run the id of a parked thread and overwrite its record. await runtime.durableRestore; monitor.beginTurn(); const config = await loadConfig(runtime.configPath); runtime.backgroundQueue.setConcurrency(config.maxConcurrentAgents || resolveSubagentConcurrency()); const discovery = discoverAgents(ctx.cwd, { scope: config.agentScope, enabledNames: config.enabledAgents, projectTrusted: ctx.isProjectTrusted?.() === true, }); const agents = discovery.agents; environmentRef.current = { ctx, config, agents }; const hasTasks = (params.tasks?.length ?? 0) > 0; const hasSingle = Boolean(params.agent) && params.task !== undefined; const catalog = agents.map((a) => a.name).join(", ") || "none"; if (Number(hasTasks) + Number(hasSingle) !== 1) { return { content: [ { type: "text", text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`, }, ], details: makeDetails("single")([]), }; } if (hasTasks) { const blankTaskIndex = params.tasks?.findIndex(({ task }) => task.trim().length === 0) ?? -1; if (blankTaskIndex !== -1) { return { content: [ { type: "text", text: `Invalid parameters. tasks[${blankTaskIndex}].task must contain at least one non-whitespace character. No background tasks were started. Enabled agents: ${catalog}.`, }, ], details: makeDetails("parallel")([]), }; } } else if (params.task?.trim().length === 0) { return { content: [ { type: "text", text: `Invalid parameters. task must contain at least one non-whitespace character. Enabled agents: ${catalog}.`, }, ], details: makeDetails("single")([]), }; } // Sub-agents run detached from the foreground turn: the editor stays // available for disjoint orchestration while the launch receipt leases // each delegated phase. The queue paces child processes without changing // phase ownership or requiring a per-call task cap. if (params.tasks && params.tasks.length > 0) { let prepared: PreparedDispatchTask[]; try { prepared = prepareDispatchTasks(params.tasks, ctx.cwd, agents); } catch (error) { throw new Error(`Parallel admission rejected: ${error instanceof Error ? error.message : String(error)} No background tasks were started.`); } const conflict = parallelAdmissionConflict(prepared, runtime.threads.values()); if (conflict) { throw new Error(`Parallel admission rejected: ${conflict}. No background tasks were started.`); } const declaredScopesComplete = prepared.every((item) => item.scope !== undefined); const admissionNote = formatParallelScopeAdmissionNote(declaredScopesComplete); // Duplicate and scope admission completes for the whole batch before any // startBackground call can allocate a run. Worktree preparation stays queued. const results = await Promise.all(prepared.map((item) => { const catalogAgent = agents.find((candidate) => candidate.name === item.agent); return startBackground( item.agent, item.task, item.cwd, defaultIsolationMode( "parallel", item.agent, item.isolation, catalogAgent ? isWorktreeCapableAgent(catalogAgent) : undefined, catalogAgent?.isolation, ), { deliveryRoute: params.wait ? "await" : "background", phaseId: item.phaseId, scope: item.scope, writeCapable: item.writeCapable, }, ); })); const startedRuns = results.filter((result) => result.exitCode === -1); const started = startedRuns.length; const startedIds = startedRuns .map((result) => result.runId) .filter((id): id is number => id !== undefined); const failureLines = results.flatMap((result, index) => { if (result.exitCode === -1) return []; const reason = getResultOutput(result).trim() || "unknown startup failure"; return [ `- tasks[${index}] (${params.tasks![index]!.agent}) failed to start: ${reason.replace(/\n/g, "\n ")}`, ]; }); if (started === 0) { // Pi marks custom-tool failures only when execute throws; returning an // `isError` property is still a successful AgentToolResult. throw new Error(`No subagents started.\n${failureLines.join("\n")}`); } if (params.wait) { const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results))); if (signal?.aborted) runtime.fallbackAwaitDelivery(startedIds); else runtime.completeAwaitDelivery(startedIds); const resultText = failureLines.length > 0 ? `${blocks}\n\nLaunch failures:\n${failureLines.join("\n")}` : blocks; const text = `${resultText}\n\n${admissionNote}`; return { content: [{ type: "text", text }], details: makeDetails("parallel", true)(results), }; } const text = [ phaseLeaseReceipt(startedIds, { mode: "parallel", declaredScopesComplete }), ...(failureLines.length > 0 ? ["Launch failures:", ...failureLines] : []), ].join("\n") + queuePacingNote(); return { content: [{ type: "text", text }], details: makeDetails("parallel", true)(results), }; } let single: PreparedDispatchTask; try { single = prepareDispatchTasks([{ agent: params.agent as string, task: params.task as string, cwd: params.cwd, phaseId: params.phaseId, scope: params.scope, isolation: params.isolation as IsolationMode | undefined, }], ctx.cwd, agents)[0]!; } catch (error) { throw new Error(`Dispatch admission rejected: ${error instanceof Error ? error.message : String(error)}`); } const singleCatalogAgent = agents.find((candidate) => candidate.name === single.agent); const result = await startBackground( single.agent, single.task, single.cwd, defaultIsolationMode( "single", single.agent, single.isolation, singleCatalogAgent ? isWorktreeCapableAgent(singleCatalogAgent) : undefined, singleCatalogAgent?.isolation, ), { deliveryRoute: params.wait ? "await" : "background", phaseId: single.phaseId, scope: single.scope, writeCapable: single.writeCapable, }, ); if (result.exitCode !== -1) { throw new Error(getResultOutput(result)); } if (params.wait && result.runId !== undefined) { const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("single", true)([result]))); if (signal?.aborted) runtime.fallbackAwaitDelivery([result.runId]); else runtime.completeAwaitDelivery([result.runId]); return { content: [{ type: "text", text: blocks }], details: makeDetails("single", true)([result]), }; } return { content: [{ type: "text", text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId], { mode: "single" }) + queuePacingNote(), }], details: makeDetails("single", true)([result]), }; }, renderCall(args, theme) { if (args.tasks && args.tasks.length > 0) { let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`; for (const t of args.tasks.slice(0, 4)) { const preview = formatTaskSummary(t.task, 48); const isolation = defaultIsolationMode("parallel", t.agent, t.isolation) === "worktree" ? " [worktree]" : ""; text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", isolation)} ${theme.fg("dim", preview)}`; } if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`; return new Text(text, 0, 0); } const task: string = args.task ?? ""; const preview = formatTaskSummary(task, 60); const isolation = args.isolation === "worktree" ? " [worktree]" : ""; return new Text( `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")}${theme.fg("dim", `${isolation}`)} ${theme.fg("dim", preview)}`, 0, 0, ); }, renderResult(result, _options, theme) { const details = result.details as SubagentDetails | undefined; if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0); if (details.mode === "single") { const r = details.results[0]; const pending = r.exitCode === -1; const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme); const usage = formatUsage(r.usage); const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`; const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : ""; const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `; const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`; return new Text(line, 0, 0); } // Parallel mode: header + one compact line per agent const lines: string[] = [ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`, ]; for (const r of details.results) { const pending = r.exitCode === -1; const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme); const usage = formatUsage(r.usage); const model = `${r.model ?? "?"}${r.modelFallbackFrom ? ` (main after ${r.modelFallbackFrom} failed)` : ""}`; const isolation = r.isolation === "worktree" ? ` · worktree ${r.integrationStatus ?? "active"}` : ""; const runId = r.runId === undefined ? "" : `${theme.fg("dim", `#${r.runId}`)} `; lines.push(` ${icon} ${runId}${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${isolation}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`); } return new Text(lines.join("\n"), 0, 0); }, }); }