/** * Subagent Tool - Delegate tasks to specialized agents * * Spawns a separate `pi` process for each subagent invocation, * giving it an isolated context window. * * Supports three modes: * - Single: { agent: "name", task: "..." } * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] } * - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] } * * Uses JSON mode to capture structured output from subagents. */ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { Message } from "@earendil-works/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai"; import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent"; import { Container, CURSOR_MARKER, Editor, Markdown, matchesKey, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js"; const MAX_PARALLEL_TASKS = 8; const MAX_CONCURRENCY = 4; const COLLAPSED_ITEM_COUNT = 10; function formatTokens(count: number): string { if (count < 1000) return count.toString(); if (count < 10000) return `${(count / 1000).toFixed(1)}k`; if (count < 1000000) return `${Math.round(count / 1000)}k`; return `${(count / 1000000).toFixed(1)}M`; } function formatUsageStats( usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens?: number; turns?: number; }, model?: string, ): string { const parts: string[] = []; if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`); if (usage.input) parts.push(`↑${formatTokens(usage.input)}`); if (usage.output) parts.push(`↓${formatTokens(usage.output)}`); if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`); if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`); if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`); if (usage.contextTokens && usage.contextTokens > 0) { parts.push(`ctx:${formatTokens(usage.contextTokens)}`); } if (model) parts.push(model); return parts.join(" "); } function formatToolCall( toolName: string, args: Record, themeFg: (color: any, text: string) => string, ): string { const shortenPath = (p: string) => { const home = os.homedir(); return p.startsWith(home) ? `~${p.slice(home.length)}` : p; }; switch (toolName) { case "bash": { const command = (args.command as string) || "..."; const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command; return themeFg("muted", "$ ") + themeFg("toolOutput", preview); } case "read": { const rawPath = (args.file_path || args.path || "...") as string; const filePath = shortenPath(rawPath); const offset = args.offset as number | undefined; const limit = args.limit as number | undefined; let text = themeFg("accent", filePath); if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ""; text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); } return themeFg("muted", "read ") + text; } case "write": { const rawPath = (args.file_path || args.path || "...") as string; const filePath = shortenPath(rawPath); const content = (args.content || "") as string; const lines = content.split("\n").length; let text = themeFg("muted", "write ") + themeFg("accent", filePath); if (lines > 1) text += themeFg("dim", ` (${lines} lines)`); return text; } case "edit": { const rawPath = (args.file_path || args.path || "...") as string; return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath)); } case "ls": { const rawPath = (args.path || ".") as string; return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath)); } case "find": { const pattern = (args.pattern || "*") as string; const rawPath = (args.path || ".") as string; return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`); } case "grep": { const pattern = (args.pattern || "") as string; const rawPath = (args.path || ".") as string; return ( themeFg("muted", "grep ") + themeFg("accent", `/${pattern}/`) + themeFg("dim", ` in ${shortenPath(rawPath)}`) ); } default: { const argsStr = JSON.stringify(args); const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr; return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`); } } } interface UsageStats { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens: number; turns: number; } type SubagentContextMode = "fresh" | "fork-current"; type SubagentMergeMode = "result-only" | "full-transcript" | "none"; type SubagentStatus = "success" | "blocked" | "failed" | "unstructured"; interface SubagentStructuredResult { status: SubagentStatus; summary: string; findings: string[]; files: string[]; changes: string[]; nextActions: string[]; openQuestions: string[]; structured: boolean; rawFinalText?: string; } interface SingleResult { agent: string; agentSource: AgentConfig["source"] | "unknown"; task: string; exitCode: number; messages: Message[]; stderr: string; usage: UsageStats; model?: string; stopReason?: string; errorMessage?: string; step?: number; contextMode?: SubagentContextMode; mergeMode?: SubagentMergeMode; subagentResult?: SubagentStructuredResult; } interface SubagentDetails { mode: "single" | "parallel" | "chain"; agentScope: AgentScope; projectAgentsDir: string | null; results: SingleResult[]; contextMode?: SubagentContextMode; mergeMode?: SubagentMergeMode; } interface RunSingleAgentOptions { inheritSessionFile?: string; contextMode?: SubagentContextMode; mergeMode?: SubagentMergeMode; requireStructuredResult?: boolean; } function getFinalOutput(messages: Message[]): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { const text = msg.content .filter((part): part is Extract => part.type === "text") .map((part) => part.text) .join("\n"); if (text) return text; } } return ""; } function sanitizeAgentWorkingInstructions(prompt: string): string { const lines = prompt.split(/\r?\n/); const outputFormatStart = lines.findIndex((line) => { const trimmed = line.trim(); return ( /^#{1,6}\s+output\s+format\b/i.test(trimmed) || /^output\s+format\b/i.test(trimmed) || /^your\s+final\s+response\b/i.test(trimmed) ); }); const kept = outputFormatStart >= 0 ? lines.slice(0, outputFormatStart) : lines; return kept.join("\n").trim(); } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0); } function normalizeSubagentFinishDetails(details: any): SubagentStructuredResult | undefined { if (!details || typeof details !== "object") return undefined; const status = ["success", "blocked", "failed"].includes(details.status) ? details.status : "success"; return { status, summary: typeof details.summary === "string" ? details.summary : "", findings: asStringArray(details.findings), files: asStringArray(details.files), changes: asStringArray(details.changes), nextActions: asStringArray(details.nextActions), openQuestions: asStringArray(details.openQuestions), structured: true, }; } function getSubagentFinishResult(messages: Message[]): SubagentStructuredResult | undefined { for (let i = messages.length - 1; i >= 0; i--) { const msg: any = messages[i]; if (msg?.role === "toolResult" && msg?.toolName === "subagent_finish") { return normalizeSubagentFinishDetails(msg.details); } } return undefined; } function getResultOutput(result: SingleResult): string { return getFinalOutput(result.messages) || result.errorMessage || result.stderr || "(no output)"; } function ensureSubagentResult(result: SingleResult): SubagentStructuredResult { if (result.subagentResult) return result.subagentResult; const rawFinalText = getResultOutput(result); const failedBeforeFinish = result.exitCode !== 0 || result.stopReason === "error" || Boolean(result.errorMessage); return { status: failedBeforeFinish ? "failed" : "unstructured", summary: failedBeforeFinish ? `Sub-agent failed before producing a structured result: ${rawFinalText}` : "Sub-agent finished without calling subagent_finish.", findings: [], files: [], changes: [], nextActions: [], openQuestions: [], structured: false, rawFinalText, }; } function renderSubagentStructuredResult(result: SingleResult): string { const structured = ensureSubagentResult(result); const section = (title: string, items: string[]) => [ `**${title}**`, "", ...(items.length > 0 ? items.map((item) => `- ${item}`) : ["None"]), ].join("\n"); return [ `### Sub-agent: ${result.agent}`, "", `**Status:** ${structured.status}${structured.structured ? "" : " (unstructured)"}`, "", "**Task**", "", result.task, "", "**Summary**", "", structured.summary || "(no summary)", "", section("Findings", structured.findings), "", section("Files", structured.files), "", section("Changes", structured.changes), "", section("Next Actions", structured.nextActions), "", section("Open Questions", structured.openQuestions), ...(structured.rawFinalText ? ["", "**Raw Final Text**", "", structured.rawFinalText] : []), ].join("\n"); } async function readSubagentFinishResultFromSession(sessionFile: string | undefined): Promise { if (!sessionFile) return undefined; let text: string; try { text = await fs.promises.readFile(sessionFile, "utf-8"); } catch { return undefined; } let found: SubagentStructuredResult | undefined; for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; try { const entry = JSON.parse(line); const msg = entry?.message; if (msg?.role === "toolResult" && msg?.toolName === "subagent_finish") { found = normalizeSubagentFinishDetails(msg.details); } } catch { /* ignore malformed trailing lines */ } } return found; } function formatMergedOutput(result: SingleResult, mergeMode: SubagentMergeMode): string { if (mergeMode === "none") return `Sub-agent ${result.agent} completed. Result merge disabled.`; if (mergeMode === "full-transcript") return getResultOutput(result); return renderSubagentStructuredResult(result); } type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record }; function getDisplayItems(messages: Message[]): DisplayItem[] { const items: DisplayItem[] = []; for (const msg of messages) { if (msg.role === "assistant") { for (const part of msg.content) { if (part.type === "text") items.push({ type: "text", text: part.text }); else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments }); } } } return items; } async function mapWithConcurrencyLimit( items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise, ): Promise { if (items.length === 0) return []; const limit = Math.max(1, Math.min(concurrency, items.length)); const results: TOut[] = new Array(items.length); let nextIndex = 0; const workers = new Array(limit).fill(null).map(async () => { while (true) { const current = nextIndex++; if (current >= items.length) return; results[current] = await fn(items[current], current); } }); await Promise.all(workers); return results; } async function writeSubagentRuntimeExtension(args: { agent: AgentConfig; task: string; contextMode: SubagentContextMode; }): Promise<{ dir: string; extensionPath: string }> { const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-runtime-")); const configPath = path.join(tmpDir, "config.json"); const extensionPath = path.join(tmpDir, "subagent-runtime.ts"); await fs.promises.writeFile( configPath, JSON.stringify( { agentName: args.agent.name, systemPrompt: sanitizeAgentWorkingInstructions(args.agent.systemPrompt), task: args.task, contextMode: args.contextMode, tools: args.agent.tools, }, null, 2, ), { encoding: "utf-8", mode: 0o600 }, ); await fs.promises.writeFile( extensionPath, [ `import * as fs from "node:fs";`, `import { StringEnum } from "@earendil-works/pi-ai";`, `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";`, `import { Type } from "typebox";`, `const config = JSON.parse(fs.readFileSync(${JSON.stringify(configPath)}, "utf-8"));`, `export default function (pi: ExtensionAPI) {`, ` let injected = false;`, ` pi.registerTool({`, ` name: "subagent_finish",`, ` label: "Finish Sub-agent",`, ` description: "Submit the final structured result for this sub-agent task. Call this exactly once when the delegated task is complete.",`, ` promptSnippet: "Submit the final structured result for the current sub-agent task",`, ` promptGuidelines: ["Use subagent_finish to end a sub-agent task with a structured result; do not rely on a final plain-text answer for task completion."],`, ` parameters: Type.Object({`, ` status: StringEnum(["success", "blocked", "failed"] as const, { description: "Outcome of the delegated task." }),`, ` summary: Type.String({ description: "Concise result summary for the parent session." }),`, ` findings: Type.Array(Type.String(), { description: "Key findings, decisions, or evidence." }),`, ` files: Type.Array(Type.String(), { description: "Relevant files inspected or changed." }),`, ` changes: Type.Array(Type.String(), { description: "Workspace changes made, or an empty array if none." }),`, ` nextActions: Type.Array(Type.String(), { description: "Recommended follow-up actions." }),`, ` openQuestions: Type.Array(Type.String(), { description: "Unresolved questions or blockers." }),`, ` }),`, ` async execute(_toolCallId, params) {`, ` return {`, ` content: [{ type: "text", text: "Sub-agent structured result captured." }],`, ` details: params,`, ` terminate: true,`, ` };`, ` },`, ` });`, ` const activateFinishTool = () => {`, ` const activeBefore = pi.getActiveTools();`, ` const requestedTools = Array.isArray(config.tools) ? config.tools.filter((tool: unknown): tool is string => typeof tool === "string" && tool.length > 0) : [];`, ` const nextTools = Array.from(new Set([...(requestedTools.length > 0 ? requestedTools : activeBefore), "subagent_finish"]));`, ` pi.setActiveTools(nextTools);`, ` };`, ` pi.on("session_start", activateFinishTool);`, ` pi.on("before_agent_start", () => {`, ` activateFinishTool();`, ` if (injected) return;`, ` injected = true;`, ` return {`, ` message: {`, ` customType: "subagent-runtime-context",`, ` display: false,`, ` content: [`, ` "[SUB-AGENT MODE]",`, ` "You are running as sub-agent: " + config.agentName + ".",`, ` "The inherited conversation context, if any, appears before this hidden child-only runtime message.",`, ` "",`, ` "Sub-agent working instructions:",`, ` config.systemPrompt || "You are a specialized sub-agent.",`, ` "",`, ` "Important: the working instructions above may contain final-output-format guidance for standalone agent runs.",`, ` "For this child runtime, those final-output-format instructions do not apply.",`, ` "",`, ` "Assigned task:",`, ` config.task || "(empty)",`, ` "",`, ` "Mandatory completion protocol:",`, ` "When the task is complete, you MUST call subagent_finish with the authoritative structured result.",`, ` "Do not produce a final plain-text answer instead of calling subagent_finish.",`, ` "subagent_finish is the only authoritative way to return results to the parent session.",`, ` "Use summary for the concise outcome, findings for evidence/analysis/decisions, files for relevant paths, changes for workspace edits, nextActions for follow-ups, and openQuestions for blockers.",`, ` "This completion protocol overrides every output-format instruction above.",`, ` ].join("\\n"),`, ` },`, ` };`, ` });`, `}`, ].join("\n"), { encoding: "utf-8", mode: 0o600 }, ); return { dir: tmpDir, extensionPath }; } function getPiInvocation(args: string[]): { command: string; args: string[] } { const currentScript = process.argv[1]; const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/"); if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) { return { command: process.execPath, args: [currentScript, ...args] }; } const execName = path.basename(process.execPath).toLowerCase(); const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName); if (!isGenericRuntime) { return { command: process.execPath, args }; } return { command: "pi", args }; } type OnUpdateCallback = (partial: AgentToolResult) => void; type RpcWritable = NodeJS.WritableStream & { writable?: boolean; destroyed?: boolean }; let subagentUiQueue: Promise = Promise.resolve(); function enqueueSubagentUi(fn: () => Promise): Promise { const next = subagentUiQueue.then(fn, fn); subagentUiQueue = next.then( () => undefined, () => undefined, ); return next; } function writeRpcLine(stdin: RpcWritable, payload: Record): void { if (stdin.destroyed || stdin.writable === false) return; stdin.write(`${JSON.stringify(payload)}\n`); } function isDialogUiMethod(method: string): boolean { return method === "select" || method === "confirm" || method === "input" || method === "editor"; } function sendCancelledUiResponse(stdin: RpcWritable, request: any): void { if (!request?.id || !isDialogUiMethod(request.method)) return; writeRpcLine(stdin, { type: "extension_ui_response", id: request.id, cancelled: true }); } async function bridgeExtensionUiRequest(request: any, parentCtx: any, stdin: RpcWritable, agentName: string): Promise { const method = String(request?.method ?? ""); const prefix = `[${agentName}] `; if (!parentCtx?.hasUI) { sendCancelledUiResponse(stdin, request); return; } try { switch (method) { case "select": { const value = await parentCtx.ui.select(prefix + (request.title ?? "Select"), Array.isArray(request.options) ? request.options : []); if (value === undefined) writeRpcLine(stdin, { type: "extension_ui_response", id: request.id, cancelled: true }); else writeRpcLine(stdin, { type: "extension_ui_response", id: request.id, value }); return; } case "confirm": { const confirmed = await parentCtx.ui.confirm(prefix + (request.title ?? "Confirm"), request.message ?? ""); writeRpcLine(stdin, { type: "extension_ui_response", id: request.id, confirmed }); return; } case "input": { const value = await parentCtx.ui.input(prefix + (request.title ?? "Input"), request.placeholder ?? ""); if (value === undefined) writeRpcLine(stdin, { type: "extension_ui_response", id: request.id, cancelled: true }); else writeRpcLine(stdin, { type: "extension_ui_response", id: request.id, value }); return; } case "editor": { const value = await parentCtx.ui.editor(prefix + (request.title ?? "Edit"), request.prefill ?? ""); if (value === undefined) writeRpcLine(stdin, { type: "extension_ui_response", id: request.id, cancelled: true }); else writeRpcLine(stdin, { type: "extension_ui_response", id: request.id, value }); return; } case "notify": { parentCtx.ui.notify(prefix + (request.message ?? ""), request.notifyType ?? "info"); return; } case "setStatus": { parentCtx.ui.setStatus(`subagent:${agentName}:${request.statusKey ?? "status"}`, request.statusText); return; } case "setWidget": { parentCtx.ui.setWidget( `subagent:${agentName}:${request.widgetKey ?? "widget"}`, request.widgetLines, request.widgetPlacement, ); return; } case "setTitle": { parentCtx.ui.setTitle(request.title ?? ""); return; } case "set_editor_text": { parentCtx.ui.setEditorText?.(request.text ?? ""); return; } default: { sendCancelledUiResponse(stdin, request); } } } catch { sendCancelledUiResponse(stdin, request); } } async function runSingleAgent( defaultCwd: string, agents: AgentConfig[], agentName: string, task: string, cwd: string | undefined, step: number | undefined, signal: AbortSignal | undefined, onUpdate: OnUpdateCallback | undefined, makeDetails: (results: SingleResult[]) => SubagentDetails, parentCtx: any, options: RunSingleAgentOptions = {}, ): Promise { const agent = agents.find((a) => a.name === agentName); if (!agent) { const available = agents.map((a) => `"${a.name}"`).join(", ") || "none"; return { agent: agentName, agentSource: "unknown", task, exitCode: 1, messages: [], stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, step, }; } const args: string[] = options.inheritSessionFile ? ["--mode", "rpc", "--session", options.inheritSessionFile] : ["--mode", "rpc", "--no-session"]; if (agent.model) args.push("--model", agent.model); let runtimeExtension: { dir: string; extensionPath: string } | undefined; const currentResult: SingleResult = { agent: agentName, agentSource: agent.source, task, exitCode: 0, messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, model: agent.model, step, contextMode: options.contextMode, mergeMode: options.mergeMode, }; const emitUpdate = () => { if (onUpdate) { onUpdate({ content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }], details: makeDetails([currentResult]), }); } }; try { runtimeExtension = await writeSubagentRuntimeExtension({ agent, task, contextMode: options.contextMode ?? "fresh" }); args.push("-e", runtimeExtension.extensionPath); // Tool selection is owned by the child runtime extension. It registers // subagent_finish, then activates agent.tools + subagent_finish during // session_start/before_agent_start. This keeps tool allocation in the same // layer as dynamic tool registration. let wasAborted = false; const exitCode = await new Promise((resolve) => { const invocation = getPiInvocation(args); const proc = spawn(invocation.command, invocation.args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PI_SUBAGENT: "1", // Subagents are internal tool executions; keep prompt timeline checkpoints on the parent session. PI_TIMELINE_DISABLED: "1", }, }); let removeAbortListener: (() => void) | undefined; let buffer = ""; let settled = false; let promptAccepted = false; const promptId = `subagent-${Date.now()}-${Math.random().toString(36).slice(2)}`; const stopProcSoon = () => { try { proc.stdin.end(); } catch { /* ignore */ } setTimeout(() => { if (!proc.killed) proc.kill("SIGTERM"); }, 250); }; const finish = (code: number) => { if (settled) return; settled = true; removeAbortListener?.(); resolve(code); }; const processAssistantMessage = (msg: Message) => { currentResult.messages.push(msg); if (msg.role === "assistant") { currentResult.usage.turns++; const usage = msg.usage; if (usage) { currentResult.usage.input += usage.input || 0; currentResult.usage.output += usage.output || 0; currentResult.usage.cacheRead += usage.cacheRead || 0; currentResult.usage.cacheWrite += usage.cacheWrite || 0; currentResult.usage.cost += usage.cost?.total || 0; currentResult.usage.contextTokens = usage.totalTokens || 0; } if (!currentResult.model && msg.model) currentResult.model = msg.model; if (msg.stopReason) currentResult.stopReason = msg.stopReason; if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage; } emitUpdate(); }; const processLine = (line: string) => { if (!line.trim()) return; let event: any; try { event = JSON.parse(line); } catch { return; } if (event.type === "response" && event.command === "prompt") { promptAccepted = Boolean(event.success); if (!event.success) { currentResult.errorMessage = event.error || event.message || "Subagent prompt was rejected"; stopProcSoon(); finish(1); } return; } if (event.type === "extension_ui_request") { void enqueueSubagentUi(() => bridgeExtensionUiRequest(event, parentCtx, proc.stdin as RpcWritable, agent.name)).catch((error) => { currentResult.stderr += `\nUI bridge error: ${error instanceof Error ? error.message : String(error)}`; sendCancelledUiResponse(proc.stdin as RpcWritable, event); }); return; } if (event.type === "message_end" && event.message) { processAssistantMessage(event.message as Message); return; } if (event.type === "tool_result_end" && event.message) { const msg = event.message as Message; currentResult.messages.push(msg); const toolResult = msg as any; if (toolResult.role === "toolResult" && toolResult.toolName === "subagent_finish") { currentResult.subagentResult = normalizeSubagentFinishDetails(toolResult.details); } emitUpdate(); return; } if (event.type === "agent_end") { if (Array.isArray(event.messages)) { currentResult.subagentResult ??= getSubagentFinishResult(event.messages as Message[]); if (currentResult.messages.length === 0) { for (const msg of event.messages) processAssistantMessage(msg as Message); } } stopProcSoon(); finish(0); } }; proc.stdout.on("data", (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) processLine(line); }); proc.stderr.on("data", (data) => { currentResult.stderr += data.toString(); }); proc.on("close", (code) => { if (buffer.trim()) { const pending = buffer; buffer = ""; processLine(pending); } finish(code ?? (promptAccepted ? 0 : 1)); }); proc.on("error", (error) => { currentResult.stderr += error instanceof Error ? error.message : String(error); finish(1); }); if (signal) { const killProc = () => { wasAborted = true; proc.kill("SIGTERM"); setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 5000); }; if (signal.aborted) killProc(); else { signal.addEventListener("abort", killProc, { once: true }); removeAbortListener = () => signal.removeEventListener("abort", killProc); } } writeRpcLine(proc.stdin as RpcWritable, { id: promptId, type: "prompt", message: `Run the delegated sub-agent task. Follow the hidden sub-agent runtime instructions. Do not produce a final plain-text answer; call subagent_finish when complete.`, }); }); currentResult.exitCode = exitCode; currentResult.subagentResult ??= getSubagentFinishResult(currentResult.messages); currentResult.subagentResult ??= await readSubagentFinishResultFromSession(options.inheritSessionFile); if (!currentResult.subagentResult) currentResult.subagentResult = ensureSubagentResult(currentResult); if ((options.requireStructuredResult ?? false) && exitCode === 0 && !currentResult.subagentResult.structured) { currentResult.stopReason = currentResult.stopReason ?? "error"; currentResult.errorMessage ??= "Sub-agent finished without calling subagent_finish."; } if (wasAborted) throw new Error("Subagent was aborted"); return currentResult; } finally { if (runtimeExtension) { try { fs.rmSync(runtimeExtension.dir, { recursive: true, force: true }); } catch { /* ignore */ } } } } const TaskItem = Type.Object({ agent: Type.String({ description: "Name of the agent to invoke" }), task: Type.String({ description: "Task to delegate to the agent" }), cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), }); const ChainItem = Type.Object({ agent: Type.String({ description: "Name of the agent to invoke" }), task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }), cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), }); const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, { description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.', default: "user", }); const ContextModeSchema = StringEnum(["fresh", "fork-current"] as const, { description: 'Sub-agent context mode. "fork-current" copies the current session byte-for-byte and appends child-only turns.', default: "fresh", }); const MergeModeSchema = StringEnum(["result-only", "full-transcript", "none"] as const, { description: 'How to merge the child result back. "result-only" keeps only a code-rendered structured sub-agent result.', default: "result-only", }); const SubagentParams = Type.Object({ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })), task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })), tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })), chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })), agentScope: Type.Optional(AgentScopeSchema), confirmProjectAgents: Type.Optional( Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }), ), cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })), contextMode: Type.Optional(ContextModeSchema), mergeMode: Type.Optional(MergeModeSchema), requireStructuredResult: Type.Optional( Type.Boolean({ description: "Treat missing subagent_finish as an error instead of an unstructured typed result. Default: false.", default: false }), ), }); async function createTempSubagentSession(): Promise<{ dir: string; filePath: string }> { const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-session-")); return { dir, filePath: path.join(dir, "session.jsonl") }; } function parseSessionLine(line: string): any | undefined { try { return JSON.parse(line); } catch { return undefined; } } function subagentToolCallIdsFromEntry(entry: any): string[] { const content = entry?.message?.content; if (entry?.type !== "message" || entry?.message?.role !== "assistant" || !Array.isArray(content)) return []; return content .filter((part: any) => part?.type === "toolCall" && part?.name === "subagent" && typeof part?.id === "string") .map((part: any) => part.id as string); } function stableSessionPrefixForFork(data: Buffer, currentToolCallId?: string): Buffer { type ParsedLine = { start: number; end: number; entry: any }; const lines: ParsedLine[] = []; let start = 0; while (start < data.length) { let end = data.indexOf(0x0a, start); if (end === -1) end = data.length; let jsonEnd = end; if (jsonEnd > start && data[jsonEnd - 1] === 0x0d) jsonEnd--; if (jsonEnd > start) { const entry = parseSessionLine(data.subarray(start, jsonEnd).toString("utf-8")); if (entry) lines.push({ start, end: end < data.length ? end + 1 : end, entry }); } start = end + 1; } const byId = new Map(); for (const line of lines) { if (typeof line.entry?.id === "string") byId.set(line.entry.id, line); } let fallbackCutoff: number | undefined; for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; const ids = subagentToolCallIdsFromEntry(line.entry); if (ids.length === 0) continue; if (currentToolCallId && !ids.includes(currentToolCallId)) { fallbackCutoff ??= line.start; continue; } // Tool-triggered fork-current runs while the parent assistant tool call is still unresolved. // Drop both the in-flight assistant tool-call entry and the user entry that triggered it; // the delegated task is appended explicitly in the child runtime. Leaving the parent user // turn as the child session leaf can cause the child to resume the wrong pending turn. const parentLine = typeof line.entry?.parentId === "string" ? byId.get(line.entry.parentId) : undefined; if (parentLine?.entry?.type === "message" && parentLine.entry?.message?.role === "user") return data.subarray(0, parentLine.start); return data.subarray(0, line.start); } return fallbackCutoff === undefined ? data : data.subarray(0, fallbackCutoff); } async function copyCurrentSessionForSubagent(ctx: any, currentToolCallId?: string): Promise<{ dir: string; filePath: string } | undefined> { const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.(); if (!sessionFile) return undefined; const session = await createTempSubagentSession(); // Prompt-cache safety requirement: preserve the inherited durable prefix byte-for-byte. // If the parent tool call is still in-flight, fork from the previous complete entry. const source = await fs.promises.readFile(sessionFile); await fs.promises.writeFile(session.filePath, stableSessionPrefixForFork(source, currentToolCallId)); return session; } async function createSubagentSessionRuntime( ctx: any, contextMode: SubagentContextMode, currentToolCallId?: string, ): Promise<{ dir: string; filePath: string; contextMode: SubagentContextMode }> { if (contextMode === "fork-current") { const copied = await copyCurrentSessionForSubagent(ctx, currentToolCallId); if (copied) return { ...copied, contextMode }; } const fresh = await createTempSubagentSession(); return { ...fresh, contextMode: "fresh" }; } function cleanupTempSubagentSession(session: { dir: string; filePath: string } | undefined): void { if (!session) return; try { fs.rmSync(session.dir, { recursive: true, force: true }); } catch { /* ignore */ } } type ChatTranscriptItem = { role: "user" | "agent" | "system"; text: string }; type ThemeLike = { fg: (color: any, text: string) => string; bg: (color: any, text: string) => string; bold: (text: string) => string; italic: (text: string) => string; }; class BorderedPanel implements Component { constructor( private readonly child: Component, private readonly theme: ThemeLike, private readonly title?: string, private readonly color: any = "borderAccent", ) {} invalidate(): void { this.child.invalidate?.(); } private border(text: string): string { return this.theme.fg(this.color, text); } private fit(text: string, width: number): string { const truncated = truncateToWidth(text, Math.max(0, width), "…"); return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); } render(width: number): string[] { const w = Math.max(1, width); if (w <= 2) return [this.border("─".repeat(w)), ...this.child.render(w), this.border("─".repeat(w))]; const innerWidth = Math.max(1, w - 4); const childLines = this.child.render(innerWidth); const title = this.title ? truncateToWidth(` ${this.title} `, Math.max(0, w - 2), "…") : ""; const remainingTop = Math.max(0, w - 2 - visibleWidth(title)); const topLeft = Math.floor(remainingTop / 2); const topRight = remainingTop - topLeft; return [ `${this.border("╭" + "─".repeat(topLeft))}${title}${this.border("─".repeat(topRight) + "╮")}`, ...childLines.map((line) => `${this.border("│")} ${this.fit(line, innerWidth)} ${this.border("│")}`), this.border("╰" + "─".repeat(Math.max(0, w - 2)) + "╯"), ]; } } class EmbeddedSubagentChat { private readonly input: Editor; private readonly transcript: ChatTranscriptItem[] = []; private running = false; private status = "ready"; private abortCurrent: (() => void) | undefined; private runningTimer: ReturnType | undefined; private liveResult: SingleResult | undefined; private scrollFromBottom = 0; private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.input.focused = value && !this.running; } constructor( private readonly tui: TUI, private readonly theme: ThemeLike, private readonly agentName: string, private readonly inheritedContext: boolean, private readonly onUserMessage: (text: string) => Promise, private readonly done: () => void, initialTranscript: ChatTranscriptItem[] = [], ) { this.transcript.push(...initialTranscript); this.input = new Editor( tui, { borderColor: (str: string) => theme.fg("warning", str), selectList: { selectedPrefix: (text: string) => theme.fg("accent", text), selectedText: (text: string) => theme.fg("accent", text), description: (text: string) => theme.fg("muted", text), scrollInfo: (text: string) => theme.fg("dim", text), noMatch: (text: string) => theme.fg("warning", text), }, }, { paddingX: 0 }, ); this.input.onSubmit = (value) => { if (this.running) return; const text = value.trim(); if (!text) { this.close(); return; } this.input.setText(""); void this.onUserMessage(text); }; } private close(): void { this.stopTimer(); this.done(); } private stopTimer(): void { if (this.runningTimer) clearInterval(this.runningTimer); this.runningTimer = undefined; } private followTail(): void { if (this.scrollFromBottom < 2) this.scrollFromBottom = 0; } addUser(text: string): void { this.transcript.push({ role: "user", text }); this.liveResult = undefined; this.followTail(); this.tui.requestRender(); } addAgent(text: string): void { this.transcript.push({ role: "agent", text }); this.liveResult = undefined; this.followTail(); this.tui.requestRender(); } addSystem(text: string): void { this.transcript.push({ role: "system", text }); this.followTail(); this.tui.requestRender(); } setLiveResult(result: SingleResult): void { this.liveResult = result; this.followTail(); this.tui.requestRender(); } setRunning(running: boolean, status: string, abort?: () => void): void { this.running = running; this.status = status; this.abortCurrent = abort; this.input.focused = this.focused && !running; if (running && !this.runningTimer) { this.runningTimer = setInterval(() => this.tui.requestRender(), 120); this.runningTimer.unref?.(); } else if (!running) { this.stopTimer(); } this.tui.requestRender(); } getTranscript(): ChatTranscriptItem[] { return [...this.transcript]; } invalidate(): void { this.input.invalidate(); } dispose(): void { this.stopTimer(); } private bodyHeight(inputLineCount: number): number { const chrome = 8 + inputLineCount; return Math.max(4, Math.min(30, this.tui.terminal.rows - chrome)); } private handleScroll(data: string, pageSize: number): boolean { if (matchesKey(data, "up")) this.scrollFromBottom += 1; else if (matchesKey(data, "down")) this.scrollFromBottom = Math.max(0, this.scrollFromBottom - 1); else if (matchesKey(data, "pageUp")) this.scrollFromBottom += pageSize; else if (matchesKey(data, "pageDown")) this.scrollFromBottom = Math.max(0, this.scrollFromBottom - pageSize); else if (matchesKey(data, "home")) this.scrollFromBottom = Number.MAX_SAFE_INTEGER; else if (matchesKey(data, "end")) this.scrollFromBottom = 0; else return false; this.tui.requestRender(); return true; } private isNewlineShortcut(data: string): boolean { return ( matchesKey(data, "shift+enter") || matchesKey(data, "shift+return") || data === "\x1b\r" || data === "\x1b[13;2~" || data === "\x1b[13;2u" || data === "\x1b[27;2;13~" || data === "\n" ); } handleInput(data: string): void { const pageSize = this.bodyHeight(this.running ? 1 : this.input.render(10).length); if (this.handleScroll(data, pageSize)) return; if (this.running) { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.abortCurrent?.(); return; } if (this.isNewlineShortcut(data)) { this.input.insertTextAtCursor("\n"); this.status = "ready"; this.tui.requestRender(); return; } if (matchesKey(data, "escape")) { if (this.input.isShowingAutocomplete()) this.input.handleInput(data); return; } if (matchesKey(data, "ctrl+d") && this.input.getText().trim().length === 0) { this.close(); return; } this.input.handleInput(data); } private fit(text: string, width: number): string { const truncated = truncateToWidth(text, Math.max(0, width), "…"); return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); } private border(text: string): string { return this.theme.fg("borderAccent", text); } private frameLine(content: string, width: number): string { if (width <= 0) return ""; if (width === 1) return this.border("│"); if (width < 4) return `${this.border("│")}${this.fit(content, Math.max(0, width - 2))}${this.border("│")}`; const innerWidth = Math.max(0, width - 4); return `${this.border("│")} ${this.fit(content, innerWidth)} ${this.border("│")}`; } private divider(width: number, left = "├", right = "┤"): string { if (width <= 2) return this.border("─".repeat(Math.max(1, width))); return this.border(left + "─".repeat(Math.max(0, width - 2)) + right); } private wrapPadded(text: string, width: number, prefix = ""): string[] { const prefixWidth = visibleWidth(prefix); const lines = wrapTextWithAnsi(text.replace(/\r\n?/g, "\n"), Math.max(1, width - prefixWidth)); return lines.length === 0 ? [prefix] : lines.map((line, index) => (index === 0 ? prefix : " ".repeat(prefixWidth)) + line); } private appendWrapped(target: string[], text: string, width: number, prefix = ""): void { for (const line of this.wrapPadded(text, width, prefix)) target.push(line); } private spinner(): string { const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; return frames[Math.floor(Date.now() / 120) % frames.length]; } private buildTranscriptLines(width: number): string[] { const lines: string[] = []; for (const item of this.transcript) { const isUser = item.role === "user"; const isAgent = item.role === "agent"; const label = isUser ? "You" : isAgent ? this.agentName : "system"; const marker = isUser ? "▶" : isAgent ? "π" : "!"; const markerColor = isUser ? "accent" : isAgent ? "success" : "warning"; const labelColor = isUser ? "userMessageText" : isAgent ? "toolTitle" : "warning"; const labelText = this.theme.fg(labelColor, this.theme.bold(label)); lines.push(`${this.theme.fg(markerColor, marker)} ${isUser ? this.theme.bg("userMessageBg", ` ${labelText} `) : labelText}`); const rawText = item.text.trim() || "(empty)"; const styledText = isUser ? this.theme.bg("userMessageBg", this.theme.fg("userMessageText", rawText)) : this.theme.fg(isAgent ? "text" : "dim", rawText); this.appendWrapped(lines, styledText, width, " "); lines.push(""); } if (this.running && this.liveResult) { const items = getDisplayItems(this.liveResult.messages).slice(-6); if (items.length > 0) { lines.push(`${this.theme.fg("warning", this.spinner())} ${this.theme.fg("toolTitle", this.theme.bold("live activity"))}`); for (const item of items) { if (item.type === "toolCall") { this.appendWrapped(lines, formatToolCall(item.name, item.args, this.theme.fg.bind(this.theme)), width, this.theme.fg("muted", " → ")); } else { const preview = item.text.trim().split("\n").slice(-5).join("\n"); this.appendWrapped(lines, this.theme.fg("toolOutput", preview), width, " "); } } lines.push(""); } } if (lines.length === 0) { lines.push(this.theme.fg("dim", "No messages yet. The initial task will appear here.")); } return lines; } render(width: number): string[] { const w = Math.max(1, width); const innerWidth = Math.max(1, w - 4); const loweredStatus = this.status.toLowerCase(); const statusColor = this.running ? "warning" : loweredStatus.includes("abort") ? "error" : loweredStatus.includes("ignored") ? "warning" : "success"; const statusText = this.running ? `${this.spinner()} ${this.status}` : this.status; const modeText = this.inheritedContext ? "mode: fork-current" : "mode: fresh"; const contextText = this.inheritedContext ? "inherited context" : "isolated context"; const header = `${this.theme.fg("accent", this.theme.bold(`$${this.agentName}`))} ${this.theme.fg("muted", "sub-agent session")} ${this.theme.fg(statusColor, statusText)}`; const context = [ this.inheritedContext ? this.theme.fg("success", `${modeText} (${contextText})`) : this.theme.fg("dim", `${modeText} (${contextText})`), this.theme.fg("dim", "↑/↓ scroll • PgUp/PgDn page"), ].join(this.theme.fg("muted", " • ")); const rawInputLines = this.running ? [this.theme.fg("warning", "Esc/Ctrl+C aborts current sub-agent turn")] : this.input.render(innerWidth); const bodyHeight = this.bodyHeight(rawInputLines.length); const body = this.buildTranscriptLines(innerWidth); const maxScroll = Math.max(0, body.length - bodyHeight); this.scrollFromBottom = Math.min(this.scrollFromBottom, maxScroll); const end = body.length - this.scrollFromBottom; const start = Math.max(0, end - bodyHeight); const visibleBody = body.slice(start, end); while (visibleBody.length < bodyHeight) visibleBody.push(""); const hint = this.running ? this.theme.fg("warning", "Running… output updates live; Esc aborts") : this.theme.fg("dim", "Enter send • Shift+Enter newline • empty Enter/Ctrl+D finish"); return [ this.divider(w, "╭", "╮"), this.frameLine(header, w), this.frameLine(context, w), this.divider(w), ...visibleBody.map((line) => this.frameLine(line, w)), this.divider(w), ...rawInputLines.map((line) => this.frameLine(line, w)), this.frameLine(hint, w), this.divider(w, "╰", "╯"), ]; } } type SubagentSaveMode = "Save final answer only" | "Save full transcript" | "Do not save" | "Back to sub-agent session"; class SubagentSaveModeSelector { focused = false; private selected = 0; private readonly items: Array<{ value: SubagentSaveMode; label: string; description: string }> = [ { value: "Save final answer only", label: "Save final answer only", description: "Default compact record: initial task + latest sub-agent answer", }, { value: "Save full transcript", label: "Save full transcript", description: "Keep every visible turn from this embedded session", }, { value: "Back to sub-agent session", label: "Back to sub-agent session", description: "Shortcut: B — continue chatting before deciding what to keep", }, { value: "Do not save", label: "Do not save", description: "Return to main chat without adding a sub-agent message", }, ]; constructor( private readonly tui: TUI, private readonly agentName: string, private readonly theme: ThemeLike, private readonly done: (mode: SubagentSaveMode) => void, ) {} invalidate(): void {} handleInput(data: string): void { let changed = false; if (matchesKey(data, "up")) { this.selected = Math.max(0, this.selected - 1); changed = true; } else if (matchesKey(data, "down")) { this.selected = Math.min(this.items.length - 1, this.selected + 1); changed = true; } else if (matchesKey(data, "home")) { this.selected = 0; changed = true; } else if (matchesKey(data, "end")) { this.selected = this.items.length - 1; changed = true; } else if (matchesKey(data, "escape")) this.done("Save final answer only"); else if (matchesKey(data, "enter") || matchesKey(data, "return")) this.done(this.items[this.selected].value); else if (data === "b" || data === "B") this.done("Back to sub-agent session"); if (changed) this.tui.requestRender(); } private fit(text: string, width: number): string { const truncated = truncateToWidth(text, Math.max(0, width), "…"); return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); } private border(text: string): string { return this.theme.fg("borderAccent", text); } private frameLine(content: string, width: number): string { if (width <= 0) return ""; if (width === 1) return this.border("│"); if (width < 4) return `${this.border("│")}${this.fit(content, Math.max(0, width - 2))}${this.border("│")}`; const innerWidth = Math.max(0, width - 4); return `${this.border("│")} ${this.fit(content, innerWidth)} ${this.border("│")}`; } private divider(width: number, left = "├", right = "┤"): string { if (width <= 2) return this.border("─".repeat(Math.max(1, width))); return this.border(left + "─".repeat(Math.max(0, width - 2)) + right); } render(width: number): string[] { const w = Math.max(1, width); const title = `${this.theme.fg("accent", this.theme.bold(`$${this.agentName}`))} ${this.theme.fg("muted", "return to main chat")}`; const subtitle = this.theme.fg("dim", "Choose what to keep. Esc chooses Save final answer only."); const lines = [this.divider(w, "╭", "╮"), this.frameLine(title, w), this.frameLine(subtitle, w), this.divider(w)]; for (let i = 0; i < this.items.length; i++) { const item = this.items[i]; const selected = i === this.selected; const pointer = selected ? this.theme.fg("accent", "›") : " "; const label = selected ? this.theme.fg("accent", this.theme.bold(item.label)) : this.theme.fg("text", item.label); lines.push(this.frameLine(`${pointer} ${label}`, w)); lines.push(this.frameLine(` ${this.theme.fg("dim", item.description)}`, w)); } lines.push(this.divider(w)); lines.push(this.frameLine(this.theme.fg("dim", "↑/↓ select • Enter confirm • B back • Esc save final answer only"), w)); lines.push(this.divider(w, "╰", "╯")); return lines; } } class RawMarkdownPreview { focused = false; private offset = 0; private readonly lines: string[]; constructor( rawMarkdown: string, private readonly tui: TUI, private readonly done: (result?: void) => void, ) { this.lines = rawMarkdown.replace(/\r\n?/g, "\n").split("\n"); if (this.lines.length === 0) this.lines.push(""); } invalidate(): void {} private contentHeight(): number { // Top divider + bottom divider + bottom hint = 3 rows. return Math.max(1, Math.min(24, Math.floor(this.tui.terminal.rows * 0.7) - 3)); } handleInput(data: string): void { const pageSize = this.contentHeight(); const maxOffset = Math.max(0, this.lines.length - pageSize); if (matchesKey(data, "return") || matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done(undefined); return; } if (matchesKey(data, "down")) this.offset = Math.min(maxOffset, this.offset + 1); else if (matchesKey(data, "up")) this.offset = Math.max(0, this.offset - 1); else if (matchesKey(data, "pageDown")) this.offset = Math.min(maxOffset, this.offset + pageSize); else if (matchesKey(data, "pageUp")) this.offset = Math.max(0, this.offset - pageSize); } render(width: number): string[] { const contentWidth = Math.max(1, width); const truncate = (text: string, max: number) => text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text; const divider = "─".repeat(contentWidth); const height = this.contentHeight(); const visible = this.lines.slice(this.offset, this.offset + height); const rendered = visible.map((line, index) => { const prefix = this.focused && index === 0 ? CURSOR_MARKER : ""; return prefix + truncate(line.replace(/\t/g, " "), contentWidth); }); while (rendered.length < height) rendered.push(""); return [divider, ...rendered, divider, "↑/↓ scroll PgUp/PgDn page Enter/Esc close"]; } } function formatAgentsForSystemPrompt(agents: AgentConfig[], maxItems = 20): string { if (agents.length === 0) { return "Available sub-agents: none found."; } const listed = agents.slice(0, maxItems); const remaining = agents.length - listed.length; const lines = [ "Available sub-agents:", ...listed.map((agent) => { const tools = agent.tools?.length ? `; tools: ${agent.tools.join(", ")}` : ""; const model = agent.model ? `; model: ${agent.model}` : ""; return `- ${agent.name} (${agent.source}): ${agent.description}${tools}${model}`; }), ]; if (remaining > 0) lines.push(`- ... ${remaining} more not shown. Use /agents to inspect all agents.`); lines.push( "Use the subagent tool when a task benefits from isolated specialist context. Set agentScope='both' to include project-local agents in .pi/agents, and keep confirmProjectAgents enabled unless the user explicitly opts out.", ); return lines.join("\n"); } function injectSubagentList(systemPrompt: string, cwd: string): string { const { agents } = discoverAgents(cwd, "both"); return `${systemPrompt}\n\n${formatAgentsForSystemPrompt(agents)}`; } export default function (pi: ExtensionAPI) { pi.on("before_agent_start", async (event, ctx) => { return { systemPrompt: injectSubagentList(event.systemPrompt, ctx.cwd) }; }); pi.registerMessageRenderer("subagent-session", (message, options, theme) => { const details = message.details as | { agent?: string; agentSource?: string; inheritedContext?: boolean; turns?: number; exitCode?: number; stopReason?: string; errorMessage?: string; model?: string; usage?: UsageStats; } | undefined; const isError = Boolean(details?.errorMessage || (details?.exitCode !== undefined && details.exitCode !== 0)); const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); const title = [ icon, theme.fg("toolTitle", theme.bold(`sub-agent ${details?.agent ?? "session"}`)), details?.agentSource ? theme.fg("muted", `(${details.agentSource})`) : "", details?.inheritedContext ? theme.fg("success", "inherited context") : theme.fg("dim", "fresh context"), ].filter(Boolean).join(" "); const container = new Container(); container.addChild(new Text(title, 0, 0)); if (isError && (details?.errorMessage || details?.stopReason)) { container.addChild(new Text(theme.fg("error", details.errorMessage || details.stopReason || "sub-agent failed"), 1, 0)); } const content = String(message.content ?? "").trim(); const lines = content.split("\n"); const visible = options.expanded ? content : lines.slice(0, 14).join("\n"); container.addChild(new Markdown(visible || "(no output)", 1, 0, getMarkdownTheme())); if (!options.expanded && lines.length > 14) { container.addChild(new Text(theme.fg("muted", `(Ctrl+O to expand • ${lines.length - 14} more lines)`), 1, 0)); } const usage = details?.usage ? formatUsageStats(details.usage, details.model) : ""; const meta = [ details?.turns ? `${details.turns} turn${details.turns > 1 ? "s" : ""}` : "", usage, ].filter(Boolean).join(" • "); if (meta) container.addChild(new Text(theme.fg("dim", meta), 1, 0)); const frameTitle = theme.fg("toolTitle", theme.bold("sub-agent result")); return new BorderedPanel(container, theme, frameTitle, isError ? "error" : "borderAccent"); }); pi.on("session_start", (_event, ctx) => { ctx.ui.addAutocompleteProvider((current) => ({ async getSuggestions(lines, cursorLine, cursorCol, options) { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); const match = beforeCursor.match(/^\$([^\s]*)$/); if (!match) { // When the sub-agent name has been completed as "$agent ", the built-in // provider treats the empty token after a space as a path-completion // request and shows the same file list as "@". Suppress that natural // empty-token completion for $ commands, while still allowing explicit // @file/path completions and forced Tab completion in the task text. if (!options.force && /^\$[^\s]*\s/.test(beforeCursor) && /\s$/.test(beforeCursor)) return null; return current.getSuggestions(lines, cursorLine, cursorCol, options); } const prefix = `$${match[1] ?? ""}`; const { agents } = discoverAgents(ctx.cwd, "both"); const items = agents .sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)) .filter((agent) => agent.name.startsWith(match[1] ?? "")) .map((agent) => ({ value: `$${agent.name} `, label: agent.name, description: `[${agent.source}] ${agent.description}`, })); return items.length > 0 ? { prefix, items } : null; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { if (!prefix.startsWith("$")) { return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); } const line = lines[cursorLine] ?? ""; const start = Math.max(0, cursorCol - prefix.length); const nextLine = line.slice(0, start) + item.value + line.slice(cursorCol); const nextLines = [...lines]; nextLines[cursorLine] = nextLine; return { lines: nextLines, cursorLine, cursorCol: start + item.value.length }; }, shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); // Disable file completion only while selecting the agent name ($scout). // Once a space is typed ($scout ...), delegate so @file completion still works. if (/^\$[^\s]*$/.test(beforeCursor)) return false; return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; }, })); }); pi.on("input", async (event, ctx) => { if (event.source === "extension" || !event.text.startsWith("$")) return { action: "continue" }; const match = event.text.match(/^\$([^\s]+)(?:\s+([\s\S]*))?$/); if (!match) { ctx.ui.notify("Use $ . Press Tab after $ to select an agent.", "warning"); return { action: "handled" }; } const agentName = match[1]; let task = (match[2] ?? "").trim(); const discovery = discoverAgents(ctx.cwd, "both"); const agent = discovery.agents.find((a) => a.name === agentName); if (!agent) { const available = discovery.agents.map((a) => a.name).join(", ") || "none"; ctx.ui.notify(`Unknown sub-agent "${agentName}". Available: ${available}`, "error"); return { action: "handled" }; } if (!task) { const entered = await ctx.ui.input(`Task for ${agent.name}`, "Describe what the sub-agent should do..."); if (!entered?.trim()) return { action: "handled" }; task = entered.trim(); } if (agent.source === "project" && ctx.hasUI) { const ok = await ctx.ui.confirm( "Run project-local agent?", `Agent: ${agent.name}\nSource: ${agent.filePath}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`, ); if (!ok) return { action: "handled" }; } const requestedContextMode: SubagentContextMode = ctx.hasUI && (await ctx.ui.confirm( "Sub-agent context", "Inherit the current conversation context?\n\nYes: fork the current session byte-for-byte and append sub-agent turns at the tail.\nNo: start with a fresh isolated sub-agent context.", )) ? "fork-current" : "fresh"; const subagentSession = await createSubagentSessionRuntime(ctx, requestedContextMode); const inheritedContext = subagentSession.contextMode === "fork-current"; if (requestedContextMode === "fork-current" && !inheritedContext) { ctx.ui.notify("Current session is ephemeral; starting sub-agent without inherited context.", "warning"); } const childRunBaseOptions = { inheritSessionFile: subagentSession.filePath, contextMode: subagentSession.contextMode, mergeMode: "result-only" as SubagentMergeMode, requireStructuredResult: false, }; let finalTranscript: ChatTranscriptItem[] = []; let lastResult: SingleResult | undefined; let saveMode: SubagentSaveMode | undefined; try { if (!ctx.hasUI) { const makeDetails = (results: SingleResult[]): SubagentDetails => ({ mode: "single", agentScope: "both", projectAgentsDir: discovery.projectAgentsDir, results, contextMode: subagentSession.contextMode, mergeMode: "result-only", }); const result = await runSingleAgent( ctx.cwd, discovery.agents, agent.name, task, undefined, undefined, undefined, undefined, makeDetails, ctx, childRunBaseOptions, ); lastResult = result; finalTranscript = [ { role: "user", text: task }, { role: "agent", text: formatMergedOutput(result, "result-only") }, ]; saveMode = "Save final answer only"; } else { let sessionTranscript: ChatTranscriptItem[] = []; let initialTurnStarted = false; while (true) { let chat: EmbeddedSubagentChat | undefined; await ctx.ui.custom((tui, theme, _keybindings, done) => { const makeDetails = (results: SingleResult[]): SubagentDetails => ({ mode: "single", agentScope: "both", projectAgentsDir: discovery.projectAgentsDir, results, contextMode: subagentSession.contextMode, mergeMode: "result-only", }); const runTurn = async (text: string) => { if (!chat) return; chat.addUser(text); const controller = new AbortController(); chat.setRunning(true, "running...", () => controller.abort()); try { const result = await runSingleAgent( ctx.cwd, discovery.agents, agent.name, text, undefined, undefined, controller.signal, (partial) => { const current = partial.details?.results[0]; if (current) chat?.setLiveResult(current); }, makeDetails, ctx, childRunBaseOptions, ); lastResult = result; const output = formatMergedOutput(result, "result-only"); chat.addAgent(output); const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; if (isError) chat.addSystem(`Sub-agent failed: ${result.errorMessage || result.stderr || result.stopReason || "unknown error"}`); } catch (error) { chat.addSystem(error instanceof Error ? error.message : String(error)); } finally { chat.setRunning(false, "ready"); } }; chat = new EmbeddedSubagentChat(tui, theme, agent.name, inheritedContext, runTurn, () => done(undefined), sessionTranscript); if (!initialTurnStarted) { initialTurnStarted = true; chat.addSystem("Sending initial message..."); setTimeout(() => void runTurn(task), 0); } return chat; }); sessionTranscript = chat?.getTranscript() ?? sessionTranscript; finalTranscript = sessionTranscript; if (finalTranscript.length === 0) break; saveMode = await ctx.ui.custom((tui, theme, _keybindings, done) => new SubagentSaveModeSelector(tui, agent.name, theme, done), ); if (saveMode === "Back to sub-agent session") continue; break; } } if (finalTranscript.length > 0) { if (!saveMode || saveMode === "Do not save") return { action: "handled" }; const lastAgentMessage = [...finalTranscript].reverse().find((item) => item.role === "agent"); const transcriptMarkdown = saveMode === "Save final answer only" ? [ `### Sub-agent: ${agent.name}`, ``, `**User**`, ``, task, ``, `**${agent.name}**`, ``, lastAgentMessage?.text ?? "(no output)", ].join("\n") : [ `### Sub-agent: ${agent.name}`, ``, ...finalTranscript.flatMap((item) => [ `**${item.role === "user" ? "User" : item.role === "agent" ? agent.name : "System"}**`, ``, item.text, ``, ]), ].join("\n"); pi.sendMessage({ customType: "subagent-session", content: transcriptMarkdown, display: true, details: { agent: agent.name, agentSource: agent.source, inheritedContext, turns: finalTranscript.filter((item) => item.role === "user").length, exitCode: lastResult?.exitCode, stopReason: lastResult?.stopReason, errorMessage: lastResult?.errorMessage, model: lastResult?.model, usage: lastResult?.usage, contextMode: subagentSession.contextMode, mergeMode: saveMode === "Save full transcript" ? "full-transcript" : "result-only", subagentResult: lastResult?.subagentResult, }, }); } } finally { cleanupTempSubagentSession(subagentSession); } return { action: "handled" }; }); pi.registerCommand("agents", { description: "Browse sub-agent definitions and preview their markdown files", getArgumentCompletions: (prefix) => { const scopes = ["user", "project", "both"]; const items = scopes.map((scope) => ({ value: scope, label: scope })); const filtered = items.filter((item) => item.value.startsWith(prefix.trim())); return filtered.length > 0 ? filtered : null; }, handler: async (args, ctx) => { const requestedScope = args.trim() || "both"; const scope: AgentScope = ["user", "project", "both"].includes(requestedScope) ? (requestedScope as AgentScope) : "both"; if (requestedScope !== scope) { ctx.ui.notify(`Unknown agent scope "${requestedScope}"; using "both".`, "warning"); } const { agents, projectAgentsDir } = discoverAgents(ctx.cwd, scope); if (agents.length === 0) { const locations = ["~/.pi/agent/agents/*.md", projectAgentsDir ? `${projectAgentsDir}/*.md` : ".pi/agents/*.md"]; ctx.ui.notify(`No sub-agents found. Checked: ${locations.join(", ")}`, "warning"); return; } const labelToAgent = new Map(); const labels = agents .sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)) .map((agent) => { const label = `${agent.name} [${agent.source}] — ${agent.description}`; labelToAgent.set(label, agent); return label; }); const selected = await ctx.ui.select(`Sub-agents (${scope})`, labels); if (!selected) return; const agent = labelToAgent.get(selected); if (!agent) return; let rawMarkdown: string; try { rawMarkdown = await fs.promises.readFile(agent.filePath, "utf-8"); } catch (error) { ctx.ui.notify(`Failed to read ${agent.filePath}: ${error instanceof Error ? error.message : String(error)}`, "error"); return; } await ctx.ui.custom((tui, _theme, _keybindings, done) => new RawMarkdownPreview(rawMarkdown, tui, done)); }, }); pi.registerTool({ name: "subagent", label: "Subagent", description: [ "Delegate tasks to specialized subagents with temporary child sessions.", "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", 'contextMode "fork-current" copies the current session byte-for-byte and appends child-only mode/task turns for prompt-cache safety.', 'Default agent scope is "user" (from ~/.pi/agent/agents).', 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").', ].join(" "), parameters: SubagentParams, async execute(toolCallId, params, signal, onUpdate, ctx) { const agentScope: AgentScope = params.agentScope ?? "user"; const contextMode: SubagentContextMode = params.contextMode ?? "fresh"; const mergeMode: SubagentMergeMode = params.mergeMode ?? "result-only"; const requireStructuredResult = params.requireStructuredResult ?? false; const discovery = discoverAgents(ctx.cwd, agentScope); const agents = discovery.agents; const confirmProjectAgents = params.confirmProjectAgents ?? true; const hasChain = (params.chain?.length ?? 0) > 0; const hasTasks = (params.tasks?.length ?? 0) > 0; const hasSingle = Boolean(params.agent && params.task); const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle); const makeDetails = (mode: "single" | "parallel" | "chain") => (results: SingleResult[]): SubagentDetails => ({ mode, agentScope, projectAgentsDir: discovery.projectAgentsDir, results, contextMode: results.find((result) => result.contextMode)?.contextMode ?? contextMode, mergeMode: results.find((result) => result.mergeMode)?.mergeMode ?? mergeMode, }); if (modeCount !== 1) { const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; return { content: [ { type: "text", text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`, }, ], details: makeDetails("single")([]), }; } if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) { const requestedAgentNames = new Set(); if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent); if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent); if (params.agent) requestedAgentNames.add(params.agent); const projectAgentsRequested = Array.from(requestedAgentNames) .map((name) => agents.find((a) => a.name === name)) .filter((a): a is AgentConfig => a?.source === "project"); if (projectAgentsRequested.length > 0) { const names = projectAgentsRequested.map((a) => a.name).join(", "); const dir = discovery.projectAgentsDir ?? "(unknown)"; const ok = await ctx.ui.confirm( "Run project-local agents?", `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`, ); if (!ok) return { content: [{ type: "text", text: "Canceled: project-local agents not approved." }], details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]), }; } } const runConfiguredAgent = async ( agentName: string, task: string, cwd: string | undefined, step: number | undefined, update: OnUpdateCallback | undefined, mode: "single" | "parallel" | "chain", ): Promise => { const runtime = await createSubagentSessionRuntime(ctx, contextMode, toolCallId); try { return await runSingleAgent( ctx.cwd, agents, agentName, task, cwd, step, signal, update, makeDetails(mode), ctx, { inheritSessionFile: runtime.filePath, contextMode: runtime.contextMode, mergeMode, requireStructuredResult, }, ); } finally { cleanupTempSubagentSession(runtime); } }; if (params.chain && params.chain.length > 0) { const results: SingleResult[] = []; let previousOutput = ""; for (let i = 0; i < params.chain.length; i++) { const step = params.chain[i]; const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput); // Create update callback that includes all previous results const chainUpdate: OnUpdateCallback | undefined = onUpdate ? (partial) => { // Combine completed results with current streaming result const currentResult = partial.details?.results[0]; if (currentResult) { const allResults = [...results, currentResult]; onUpdate({ content: partial.content, details: makeDetails("chain")(allResults), }); } } : undefined; const result = await runConfiguredAgent( step.agent, taskWithContext, step.cwd, i + 1, chainUpdate, "chain", ); results.push(result); const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; if (isError) { const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; return { content: [ { type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}\n\n${formatMergedOutput(result, mergeMode)}`, }, ], details: makeDetails("chain")(results), isError: true, }; } previousOutput = formatMergedOutput(result, mergeMode); } return { content: [{ type: "text", text: formatMergedOutput(results[results.length - 1], mergeMode) }], details: makeDetails("chain")(results), }; } if (params.tasks && params.tasks.length > 0) { if (params.tasks.length > MAX_PARALLEL_TASKS) return { content: [ { type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, }, ], details: makeDetails("parallel")([]), }; // Track all results for streaming updates const allResults: SingleResult[] = new Array(params.tasks.length); // Initialize placeholder results for (let i = 0; i < params.tasks.length; i++) { allResults[i] = { agent: params.tasks[i].agent, agentSource: "unknown", task: params.tasks[i].task, exitCode: -1, // -1 = still running messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, }; } const emitParallelUpdate = () => { if (onUpdate) { const running = allResults.filter((r) => r.exitCode === -1).length; const done = allResults.filter((r) => r.exitCode !== -1).length; onUpdate({ content: [ { type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }, ], details: makeDetails("parallel")([...allResults]), }); } }; const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { const result = await runConfiguredAgent( t.agent, t.task, t.cwd, undefined, // Per-task update callback (partial) => { if (partial.details?.results[0]) { allResults[index] = partial.details.results[0]; emitParallelUpdate(); } }, "parallel", ); allResults[index] = result; emitParallelUpdate(); return result; }); const successCount = results.filter((r) => r.exitCode === 0).length; const summaries = results.map((r) => { const output = formatMergedOutput(r, mergeMode); const preview = output.slice(0, 100) + (output.length > 100 ? "..." : ""); return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`; }); return { content: [ { type: "text", text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`, }, ], details: makeDetails("parallel")(results), }; } if (params.agent && params.task) { const result = await runConfiguredAgent( params.agent, params.task, params.cwd, undefined, onUpdate, "single", ); const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; if (isError) { const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; return { content: [ { type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}\n\n${formatMergedOutput(result, mergeMode)}`, }, ], details: makeDetails("single")([result]), isError: true, }; } return { content: [{ type: "text", text: formatMergedOutput(result, mergeMode) }], details: makeDetails("single")([result]), }; } const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; return { content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }], details: makeDetails("single")([]), }; }, renderCall(args, theme, _context) { const scope: AgentScope = args.agentScope ?? "user"; const contextMode: SubagentContextMode = args.contextMode ?? "fresh"; const modeLabel = contextMode === "fork-current" ? "fork-current / inherited" : "fresh / isolated"; const modeBadge = theme.fg(contextMode === "fork-current" ? "success" : "dim", modeLabel); const meta = theme.fg("muted", `[${scope} • `) + modeBadge + theme.fg("muted", `]`); if (args.chain && args.chain.length > 0) { let text = theme.fg("toolTitle", theme.bold("subagent ")) + theme.fg("accent", `chain (${args.chain.length} steps)`) + meta; for (let i = 0; i < Math.min(args.chain.length, 3); i++) { const step = args.chain[i]; // Clean up {previous} placeholder for display const cleanTask = step.task.replace(/\{previous\}/g, "").trim(); const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask; text += "\n " + theme.fg("muted", `${i + 1}.`) + " " + theme.fg("accent", step.agent) + theme.fg("dim", ` ${preview}`); } if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`; return new Text(text, 0, 0); } if (args.tasks && args.tasks.length > 0) { let text = theme.fg("toolTitle", theme.bold("subagent ")) + theme.fg("accent", `parallel (${args.tasks.length} tasks)`) + meta; for (const t of args.tasks.slice(0, 3)) { const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task; text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`; } if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`; return new Text(text, 0, 0); } const agentName = args.agent || "..."; const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "..."; let text = theme.fg("toolTitle", theme.bold("subagent ")) + theme.fg("accent", agentName) + meta; text += `\n ${theme.fg("dim", preview)}`; return new Text(text, 0, 0); }, renderResult(result, { expanded }, theme, _context) { const details = result.details as SubagentDetails | undefined; if (!details || details.results.length === 0) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); } const mdTheme = getMarkdownTheme(); const resultMode = details.contextMode ?? "fresh"; const resultModeLabel = resultMode === "fork-current" ? "fork-current / inherited" : "fresh / isolated"; const resultModeMeta = theme.fg("muted", "mode: ") + theme.fg(resultMode === "fork-current" ? "success" : "dim", resultModeLabel); const renderDisplayItems = (items: DisplayItem[], limit?: number) => { const toShow = limit ? items.slice(-limit) : items; const skipped = limit && items.length > limit ? items.length - limit : 0; let text = ""; if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`); for (const item of toShow) { if (item.type === "text") { const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n"); text += `${theme.fg("toolOutput", preview)}\n`; } else { text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`; } } return text.trimEnd(); }; if (details.mode === "single" && details.results.length === 1) { const r = details.results[0]; const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted"; const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); const displayItems = getDisplayItems(r.messages); const finalOutput = getFinalOutput(r.messages); if (expanded) { const container = new Container(); let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)} ${resultModeMeta}`; if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`; container.addChild(new Text(header, 0, 0)); if (isError && r.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0)); container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0)); container.addChild(new Text(theme.fg("dim", r.task), 0, 0)); container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0)); if (displayItems.length === 0 && !finalOutput) { container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0)); } else { for (const item of displayItems) { if (item.type === "toolCall") container.addChild( new Text( theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0, ), ); } if (finalOutput) { container.addChild(new Spacer(1)); container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); } } const usageStr = formatUsageStats(r.usage, r.model); if (usageStr) { container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("dim", usageStr), 0, 0)); } return container; } let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)} ${resultModeMeta}`; if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`; if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`; else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`; else { text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`; if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; } const usageStr = formatUsageStats(r.usage, r.model); if (usageStr) text += `\n${theme.fg("dim", usageStr)}`; return new Text(text, 0, 0); } const aggregateUsage = (results: SingleResult[]) => { const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }; for (const r of results) { total.input += r.usage.input; total.output += r.usage.output; total.cacheRead += r.usage.cacheRead; total.cacheWrite += r.usage.cacheWrite; total.cost += r.usage.cost; total.turns += r.usage.turns; } return total; }; if (details.mode === "chain") { const successCount = details.results.filter((r) => r.exitCode === 0).length; const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗"); if (expanded) { const container = new Container(); container.addChild( new Text( icon + " " + theme.fg("toolTitle", theme.bold("chain ")) + theme.fg("accent", `${successCount}/${details.results.length} steps`) + " " + resultModeMeta, 0, 0, ), ); for (const r of details.results) { const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); const displayItems = getDisplayItems(r.messages); const finalOutput = getFinalOutput(r.messages); container.addChild(new Spacer(1)); container.addChild( new Text( `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0, ), ); container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0)); // Show tool calls for (const item of displayItems) { if (item.type === "toolCall") { container.addChild( new Text( theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0, ), ); } } // Show final output as markdown if (finalOutput) { container.addChild(new Spacer(1)); container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); } const stepUsage = formatUsageStats(r.usage, r.model); if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0)); } const usageStr = formatUsageStats(aggregateUsage(details.results)); if (usageStr) { container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0)); } return container; } // Collapsed view let text = icon + " " + theme.fg("toolTitle", theme.bold("chain ")) + theme.fg("accent", `${successCount}/${details.results.length} steps`) + " " + resultModeMeta; for (const r of details.results) { const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); const displayItems = getDisplayItems(r.messages); text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`; if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`; else text += `\n${renderDisplayItems(displayItems, 5)}`; } const usageStr = formatUsageStats(aggregateUsage(details.results)); if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`; text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; return new Text(text, 0, 0); } if (details.mode === "parallel") { const running = details.results.filter((r) => r.exitCode === -1).length; const successCount = details.results.filter((r) => r.exitCode === 0).length; const failCount = details.results.filter((r) => r.exitCode > 0).length; const isRunning = running > 0; const icon = isRunning ? theme.fg("warning", "⏳") : failCount > 0 ? theme.fg("warning", "◐") : theme.fg("success", "✓"); const status = isRunning ? `${successCount + failCount}/${details.results.length} done, ${running} running` : `${successCount}/${details.results.length} tasks`; if (expanded && !isRunning) { const container = new Container(); container.addChild( new Text( `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)} ${resultModeMeta}`, 0, 0, ), ); for (const r of details.results) { const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); const displayItems = getDisplayItems(r.messages); const finalOutput = getFinalOutput(r.messages); container.addChild(new Spacer(1)); container.addChild( new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0), ); container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0)); // Show tool calls for (const item of displayItems) { if (item.type === "toolCall") { container.addChild( new Text( theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0, ), ); } } // Show final output as markdown if (finalOutput) { container.addChild(new Spacer(1)); container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); } const taskUsage = formatUsageStats(r.usage, r.model); if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0)); } const usageStr = formatUsageStats(aggregateUsage(details.results)); if (usageStr) { container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0)); } return container; } // Collapsed view (or still running) let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)} ${resultModeMeta}`; for (const r of details.results) { const rIcon = r.exitCode === -1 ? theme.fg("warning", "⏳") : r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); const displayItems = getDisplayItems(r.messages); text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`; if (displayItems.length === 0) text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`; else text += `\n${renderDisplayItems(displayItems, 5)}`; } if (!isRunning) { const usageStr = formatUsageStats(aggregateUsage(details.results)); if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`; } if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; return new Text(text, 0, 0); } const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); }, }); }