/** * pi-echo-subagents * * Delegate tasks to specialized subagents with isolated context, adapted from * Pi's own official example (examples/extensions/subagent/). Each invocation * spawns a separate `pi` child process (`--mode json -p --no-session`), giving * true OS-level context isolation for free — no SessionManager.fork()/worktree * isolation needed. * * Design note: this package deliberately trades the official example's rich * TUI rendering (custom renderCall/renderResult with themed Container output) * for the default tool-result display, to keep this Phase B module smaller * and easier to verify. The mechanism (subprocess spawn, JSON-event parsing, * agent discovery, single/parallel/chain modes) is preserved in full; richer * rendering can be layered on later without touching this logic. * * Permission-gate composition (verified against the installed pi-coding-agent * source, dist/main.js): a spawned child inherits `pi-echo-permissions`' gate * "for free" ONLY if the project has already been trusted at least once * interactively — extension auto-discovery in the child is entirely * independent of `--no-session`, but project-local extensions still require * project trust, and trust is resolved per-cwd from a persistent trust store * shared between parent and child. In the near-universal case where the * user's own interactive session already trusted this project before ever * invoking a subagent, the child reads that same cached trust and the gate * applies normally. In the narrow first-ever-run-in-an-untrusted-directory * edge case, a headless child cannot prompt for trust and project-local * extensions (including pi-echo-permissions itself) silently do not load for * that child — see SECURITY.md. */ 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 { CONFIG_DIR_NAME, type ExtensionAPI, getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts"; const MAX_PARALLEL_TASKS = 8; const MAX_CONCURRENCY = 4; const PER_TASK_OUTPUT_CAP_BYTES = 50 * 1024; interface UsageStats { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens: number; turns: number; } interface SingleResult { agent: string; agentSource: "user" | "project" | "unknown"; task: string; exitCode: number; messages: Message[]; stderr: string; usage: UsageStats; model?: string; stopReason?: string; errorMessage?: string; step?: number; } function getFinalOutput(messages: Message[]): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg && msg.role === "assistant") { for (const part of msg.content) { if (part.type === "text") return part.text; } } } return ""; } function isFailedResult(result: SingleResult): boolean { return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; } function getResultOutput(result: SingleResult): string { if (isFailedResult(result)) { return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; } return getFinalOutput(result.messages) || "(no output)"; } 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] as TIn, current); } }); await Promise.all(workers); return results; } async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> { const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "echo-subagent-")); const safeName = agentName.replace(/[^\w.-]+/g, "_"); const filePath = path.join(tmpDir, `prompt-${safeName}.md`); await withFileMutationQueue(filePath, async () => { await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 }); }); return { dir: tmpDir, filePath }; } /** Resolve how to re-invoke `pi` from within this extension's own process. */ 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; async function runSingleAgent( defaultCwd: string, agents: AgentConfig[], agentName: string, task: string, cwd: string | undefined, step: number | undefined, signal: AbortSignal | undefined, onUpdate: OnUpdateCallback | undefined, ): 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[] = ["--mode", "json", "-p", "--no-session"]; if (agent.model) args.push("--model", agent.model); if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); let tmpPromptDir: string | null = null; let tmpPromptPath: string | null = null; 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, }; const emitUpdate = () => { onUpdate?.({ content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }], details: undefined, }); }; try { if (agent.systemPrompt.trim()) { const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt); tmpPromptDir = tmp.dir; tmpPromptPath = tmp.filePath; args.push("--append-system-prompt", tmpPromptPath); } args.push(`Task: ${task}`); 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: ["ignore", "pipe", "pipe"], }); let buffer = ""; const processLine = (line: string) => { if (!line.trim()) return; let event: any; try { event = JSON.parse(line); } catch { return; } if (event.type === "message_end" && event.message) { const msg = event.message as Message; currentResult.messages.push(msg); if (msg.role === "assistant") { currentResult.usage.turns++; const usage = (msg as any).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 as any).model) currentResult.model = (msg as any).model; if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason; if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage; } emitUpdate(); } if (event.type === "tool_result_end" && event.message) { currentResult.messages.push(event.message as Message); emitUpdate(); } }; 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()) processLine(buffer); resolve(code ?? 0); }); proc.on("error", () => { resolve(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 }); } }); currentResult.exitCode = exitCode; if (wasAborted) throw new Error("Subagent was aborted"); return currentResult; } finally { if (tmpPromptPath) { try { fs.unlinkSync(tmpPromptPath); } catch { /* ignore */ } } if (tmpPromptDir) { try { fs.rmdirSync(tmpPromptDir); } catch { /* ignore */ } } } } function truncateForSummary(text: string): string { const byteLength = Buffer.byteLength(text, "utf8"); if (byteLength <= PER_TASK_OUTPUT_CAP_BYTES) return text; let truncated = text.slice(0, PER_TASK_OUTPUT_CAP_BYTES); while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP_BYTES) { truncated = truncated.slice(0, -1); } return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`; } 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 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)" })), }); export default function (pi: ExtensionAPI): void { pi.registerTool({ name: "subagent", label: "Subagent", description: [ "Delegate tasks to specialized subagents with isolated context (each runs as a separate pi process).", "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`, `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" (or "project").`, ].join(" "), parameters: SubagentParams, async execute(_toolCallId, params, signal, onUpdate, ctx) { const agentScope: AgentScope = params.agentScope ?? "user"; 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); 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: undefined, }; } 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: undefined, }; } } } if (params.chain && params.chain.length > 0) { const results: SingleResult[] = []; let previousOutput = ""; for (const [i, step] of params.chain.entries()) { const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput); const result = await runSingleAgent( ctx.cwd, agents, step.agent, taskWithContext, step.cwd, i + 1, signal, onUpdate, ); results.push(result); if (isFailedResult(result)) { const errorMsg = getResultOutput(result); return { content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }], details: undefined, isError: true, }; } previousOutput = getFinalOutput(result.messages); } const last = results[results.length - 1]; return { content: [{ type: "text", text: last ? getFinalOutput(last.messages) || "(no output)" : "(no output)" }], details: undefined, }; } 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: undefined, }; } const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, (t) => runSingleAgent(ctx.cwd, agents, t.agent, t.task, t.cwd, undefined, signal, undefined), ); const successCount = results.filter((r) => !isFailedResult(r)).length; const summaries = results.map((r) => { const output = truncateForSummary(getResultOutput(r)); const status = isFailedResult(r) ? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}` : "completed"; return `### [${r.agent}] ${status}\n\n${output}`; }); return { content: [{ type: "text", text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}` }], details: undefined, }; } if (params.agent && params.task) { const result = await runSingleAgent(ctx.cwd, agents, params.agent, params.task, params.cwd, undefined, signal, onUpdate); if (isFailedResult(result)) { const errorMsg = getResultOutput(result); return { content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }], details: undefined, isError: true, }; } return { content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }], details: undefined, }; } const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; return { content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }], details: undefined }; }, }); }