/** * Subagents — spawn background subagents on one of three backends * (pi, Claude Code, Codex) unified behind a single Effect service interface. * * Tools (for the parent LLM): * - subagent_spawn: fire-and-forget spawn (prompt, title, agent, working_dir, * model, reasoning_effort). Max 4 running at once across all backends. * - subagent_wait: block until the listed subagents settle, return results. * - subagent_cancel: stop one or more running subagents. * - subagent_check: peek at a subagent's status and recent activity. * - subagent_list: list all subagents. * * Unawaited subagents queue their result as a follow-up message when they * settle. `/subagents` opens a picker + full interactive takeover view. * * Architecture: Effect v4 generators throughout (backends -> manager -> * runtime); this file is the async boundary where tool handlers run effects * against one shared ManagedRuntime. All three backends are real: pi runs * in-process SDK sessions, claude drives the Claude Agent SDK, codex speaks * JSON-RPC to a scoped `codex app-server` process. */ import * as fs from "node:fs"; import * as path from "node:path"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ExtensionUIContext, } from "@earendil-works/pi-coding-agent"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, getAgentDir, getMarkdownTheme, ProjectTrustStore, truncateHead, } from "@earendil-works/pi-coding-agent"; import { Markdown, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { deriveBtwTitle, isModelVisible } from "./src/by-the-way.ts"; import { BACKEND_NAMES, formatElapsed, latestText, REASONING_EFFORTS, type SubagentSnapshot, } from "./src/domain.ts"; import { formatActivityStatus, formatContextUtilization, } from "./src/format.ts"; import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts"; import { buildSubagentResultMessage, buildSubagentSpawnResult, SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS, SUBAGENT_CANCEL_TOOL_DESCRIPTION, SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS, SUBAGENT_CHECK_TOOL_DESCRIPTION, SUBAGENT_LIST_TOOL_DESCRIPTION, SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS, SUBAGENT_SPAWN_PROMPT_GUIDELINES, SUBAGENT_SPAWN_PROMPT_SNIPPET, SUBAGENT_SPAWN_TOOL_DESCRIPTION, SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS, SUBAGENT_WAIT_TOOL_DESCRIPTION, } from "./src/prompt.ts"; import { createDeferredResultDelivery } from "./src/result-delivery.ts"; import { createSubagentRuntime, runTool, type SubagentRuntime, } from "./src/runtime.ts"; import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts"; const SUBAGENT_OUTPUT_MAX_BYTES = 24 * 1024; const WAIT_OUTPUT_MAX_BYTES = 48 * 1024; const WAIT_PER_AGENT_MAX_BYTES = 16 * 1024; interface BtwResultData { readonly id: string; readonly title: string; readonly status: SubagentSnapshot["status"]; readonly errorText?: string; readonly prompt: string; readonly answer: string; readonly sessionFilePath?: string; } function describeSubagent(snap: SubagentSnapshot) { const details = [ `${snap.backend}: ${snap.meta.modelLabel ?? "?"}`, formatContextUtilization(snap.usage), formatElapsed(snap), snap.cwd, ].filter(Boolean); return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`; } function truncatedOutput( snap: SubagentSnapshot, maxBytes = SUBAGENT_OUTPUT_MAX_BYTES, ): string { const output = snap.finalText || "(no output)"; const truncation = truncateHead(output, { maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES), maxLines: Math.min(600, DEFAULT_MAX_LINES), }); let text = truncation.content; if (truncation.truncated) { text += `\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)} shown. Full transcript in session file: ${snap.meta.sessionFilePath ?? "?"}]`; } return text; } /** * Same-directory children inherit the live parent decision. An alternate cwd * is trusted only when pi's persisted trust store explicitly trusts it (or a * containing directory); unreadable/invalid trust data fails closed. */ function resolveChildProjectTrust(options: { parentCwd: string; childCwd: string; parentTrusted: boolean; }) { if (path.resolve(options.childCwd) === path.resolve(options.parentCwd)) { return options.parentTrusted; } try { const trustStore = new ProjectTrustStore(getAgentDir()); return trustStore.get(options.childCwd) === true; } catch { return false; } } export default function (pi: ExtensionAPI) { let runtime: SubagentRuntime | undefined; let managerPromise: Promise | undefined; let sessionContext: ExtensionContext | undefined; let ui: ExtensionUIContext | undefined; let unsubStatus: (() => void) | undefined; const resultDelivery = createDeferredResultDelivery(); const getRuntime = () => (runtime ??= createSubagentRuntime()); /** Resolve the manager service once per runtime and wire the extension hooks. */ const getManager = () => { managerPromise ??= getRuntime() .runPromise(SubagentManager) .then((manager) => { manager.view.setOnSettled(onSettled); unsubStatus?.(); unsubStatus = manager.view.subscribe(() => updateStatus(manager)); updateStatus(manager); return manager; }); return managerPromise; }; const updateStatus = (manager: SubagentManagerShape) => { if (!ui) return; const subs = manager.view.list(); if (subs.length === 0) { ui.setStatus("subagents", undefined); return; } const running = subs.filter((snap) => snap.status === "running").length; const failed = subs.filter((snap) => snap.status === "error").length; const done = subs.length - running - failed; ui.setStatus( "subagents", formatActivityStatus(ui.theme, { running, done, failed }), ); }; const deliverResult = (snap: SubagentSnapshot) => { pi.sendMessage( { customType: "subagent-result", content: buildSubagentResultMessage({ id: snap.id, title: snap.title, status: snap.status, errorText: snap.errorText, output: truncatedOutput(snap), }), display: true, details: { id: snap.id, title: snap.title, status: snap.status }, }, { deliverAs: "followUp", triggerTurn: true }, ); }; const flushResults = () => { for (const snap of resultDelivery.drain()) deliverResult(snap); }; const deliverBtwResult = (snap: SubagentSnapshot) => { // appendEntry is a synchronous SessionManager operation and emits an // entry_appended event, so it is safe while the parent is streaming and // never enters the model's context or follow-up queue. pi.appendEntry("btw-result", { id: snap.id, title: snap.title, status: snap.status, errorText: snap.errorText, prompt: snap.prompt, answer: truncatedOutput(snap), sessionFilePath: snap.meta.sessionFilePath, }); ui?.notify( snap.status === "error" ? `by the way “${snap.title}” failed — reopen it with /subagents` : `by the way “${snap.title}” answered — reopen it with /subagents`, snap.status === "error" ? "error" : "info", ); }; const onSettled = (snap: SubagentSnapshot, consumed: boolean) => { // A shutdown can settle children while disposing their scopes. Never // append into a session whose extension runtime is already closing. if (!sessionContext) return; if (snap.origin === "btw") { deliverBtwResult({ ...snap, meta: { ...snap.meta } }); return; } if (consumed) { resultDelivery.consume([snap.id]); return; } // Keep the result retractable while the parent is working. A later // subagent_wait can consume it before agent_settled flushes follow-ups. // Defer a copy: the live snapshot keeps mutating if the subagent is // restarted before the deferred result flushes. resultDelivery.defer({ ...snap, meta: { ...snap.meta } }); if (sessionContext?.isIdle()) flushResults(); }; pi.on("session_start", (_event, ctx) => { sessionContext = ctx; if (ctx.hasUI) ui = ctx.ui; }); pi.on("agent_settled", flushResults); pi.on("session_shutdown", async () => { sessionContext = undefined; resultDelivery.clear(); unsubStatus?.(); unsubStatus = undefined; ui?.setStatus("subagents", undefined); ui = undefined; const closing = runtime; runtime = undefined; managerPromise = undefined; // Disposing the runtime runs the manager finalizer, which tears down all // subagent scopes (and, later, their real child processes). await closing?.dispose(); }); // --- Tools ------------------------------------------------------------- pi.registerTool({ name: "subagent_spawn", label: "Spawn Subagent", description: SUBAGENT_SPAWN_TOOL_DESCRIPTION, promptSnippet: SUBAGENT_SPAWN_PROMPT_SNIPPET, promptGuidelines: SUBAGENT_SPAWN_PROMPT_GUIDELINES, parameters: Type.Object({ prompt: Type.String({ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.prompt, }), name: Type.String({ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.name, }), harness: StringEnum(BACKEND_NAMES, { description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.harness, }), working_dir: Type.Optional( Type.String({ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.workingDir, }), ), model: Type.Optional( Type.String({ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.model, }), ), reasoning_effort: Type.Optional( StringEnum(REASONING_EFFORTS, { description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort, }), ), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const manager = await getManager(); const harness = params.harness; const cwd = path.resolve(ctx.cwd, params.working_dir ?? "."); if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) { throw new Error(`working_dir is not a directory: ${cwd}`); } const title = params.name.trim().slice(0, 160) || "subagent"; const snap = await runTool( getRuntime(), manager.spawn(harness, { prompt: params.prompt, title, cwd, model: params.model, reasoningEffort: params.reasoning_effort, parent: { parentCwd: ctx.cwd, projectTrusted: resolveChildProjectTrust({ parentCwd: ctx.cwd, childCwd: cwd, parentTrusted: ctx.isProjectTrusted(), }), inheritedModel: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined, inheritedThinkingLevel: pi.getThinkingLevel(), modelRegistry: ctx.modelRegistry, }, }), { signal, interruptMessage: "Subagent spawn aborted." }, ); return { content: [ { type: "text", text: buildSubagentSpawnResult({ id: snap.id, title: snap.title, harness, modelLabel: snap.meta.modelLabel ?? "?", cwd, }), }, ], details: { id: snap.id, title: snap.title, cwd, harness, model: snap.meta.modelLabel, }, }; }, }); pi.registerTool({ name: "subagent_wait", label: "Wait for Subagents", description: SUBAGENT_WAIT_TOOL_DESCRIPTION, parameters: Type.Object({ ids: Type.Array(Type.String(), { maxItems: 64, description: SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS.ids, }), }), async execute(_toolCallId, params, signal, onUpdate) { const manager = await getManager(); const ids = [...new Set(params.ids)]; if (ids.length === 0) throw new Error("Provide at least one subagent id."); const known = manager.view .list() .filter(isModelVisible) .map((snap) => snap.id); const unknown = ids.filter((id) => { const snap = manager.view.get(id); return !snap || !isModelVisible(snap); }); if (unknown.length > 0) { throw new Error( `Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`, ); } await runTool( getRuntime(), manager.waitFor(ids, (pending) => { onUpdate?.({ content: [ { type: "text", text: `Waiting for ${pending.join(", ")}...` }, ], details: { pending }, }); }), { signal, interruptMessage: "Wait aborted. Subagents keep running." }, ); // Settlement may have happened before this wait began. Remove any // deferred automatic delivery now that the tool is returning the result. resultDelivery.consume(ids); const sections: string[] = []; let remainingBytes = WAIT_OUTPUT_MAX_BYTES; for (const id of ids) { const snap = manager.view.get(id); if (!snap) { sections.push(`## ${id}\n\n(no longer tracked)`); continue; } const verb = snap.status === "error" ? "failed" : "finished"; let section = `## ${snap.id} "${snap.title}" ${verb}`; if (snap.errorText) section += `\nError: ${snap.errorText}`; const headerBytes = Buffer.byteLength(section, "utf8") + 2; const outputBudget = Math.max( 512, Math.min(WAIT_PER_AGENT_MAX_BYTES, remainingBytes - headerBytes), ); section += `\n\n${truncatedOutput(snap, outputBudget)}`; const sectionBytes = Buffer.byteLength(section, "utf8"); if (sectionBytes > remainingBytes) { sections.push( `## ${snap.id} "${snap.title}"\n\n[omitted: total wait output limit reached]`, ); break; } sections.push(section); remainingBytes -= sectionBytes; } const combined = sections.join("\n\n---\n\n"); const bounded = truncateHead(combined, { maxBytes: WAIT_OUTPUT_MAX_BYTES - 128, maxLines: DEFAULT_MAX_LINES, }); const text = bounded.truncated ? `${bounded.content}\n\n[wait output truncated at the total output limit]` : bounded.content; return { content: [{ type: "text", text }], details: { results: ids.map((id) => { const snap = manager.view.get(id); return { id, title: snap?.title, status: snap?.status }; }), }, }; }, }); pi.registerTool({ name: "subagent_cancel", label: "Cancel Subagents", description: SUBAGENT_CANCEL_TOOL_DESCRIPTION, parameters: Type.Object({ ids: Type.Array(Type.String(), { description: SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS.ids, }), }), async execute(_toolCallId, params, signal) { const manager = await getManager(); const ids = [...new Set(params.ids)]; if (ids.length === 0) throw new Error("Provide at least one subagent id."); const known = manager.view .list() .filter(isModelVisible) .map((snap) => snap.id); const unknown = ids.filter((id) => { const snap = manager.view.get(id); return !snap || !isModelVisible(snap); }); if (unknown.length > 0) { throw new Error( `Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`, ); } const report = await runTool(getRuntime(), manager.cancel(ids), { signal, interruptMessage: "Subagent cancellation aborted.", }); const lines = report.map((entry) => entry.cancelled ? `Cancelled ${entry.id} "${entry.title}".` : `${entry.id} "${entry.title}" was already ${entry.status}.`, ); return { content: [{ type: "text", text: lines.join("\n") }], details: { results: report.map((entry) => ({ id: entry.id, title: entry.title, status: entry.status, })), }, }; }, }); pi.registerTool({ name: "subagent_check", label: "Check Subagent", description: SUBAGENT_CHECK_TOOL_DESCRIPTION, parameters: Type.Object({ id: Type.String({ description: SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS.id, }), }), async execute(_toolCallId, params) { const manager = await getManager(); const snap = manager.view.get(params.id); if (!snap || !isModelVisible(snap)) { const known = manager.view .list() .filter(isModelVisible) .map((s) => s.id); throw new Error( `Unknown subagent id "${params.id}". Known: ${known.join(", ") || "none"}.`, ); } let text = `${describeSubagent(snap)}\nTurns: ${snap.turns}`; if (snap.errorText) text += `\nError: ${snap.errorText}`; const output = latestText(snap); if (output) { const preview = truncateHead(output, { maxBytes: 2048, maxLines: 20 }); text += `\n\nLatest output:\n${preview.content}`; if (preview.truncated) text += "\n[...]"; } else if (snap.status === "running") { text += "\n\n(no text output yet)"; } return { content: [{ type: "text", text }], details: { id: snap.id, status: snap.status, turns: snap.turns }, }; }, }); pi.registerTool({ name: "subagent_list", label: "List Subagents", description: SUBAGENT_LIST_TOOL_DESCRIPTION, parameters: Type.Object({}), async execute() { const manager = await getManager(); const subs = manager.view.list().filter(isModelVisible); const text = subs.length === 0 ? "No subagents." : subs.map((snap) => describeSubagent(snap)).join("\n"); return { content: [{ type: "text", text }], details: { subagents: subs.map((snap) => ({ id: snap.id, title: snap.title, harness: snap.backend, status: snap.status, })), }, }; }, }); // --- Result message rendering ------------------------------------------ pi.registerMessageRenderer( "subagent-result", (message, { expanded }, theme) => { const details = (message.details ?? {}) as { id?: string; title?: string; status?: string; }; const failed = details.status === "error"; const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■"); const header = `${icon} ` + theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) + theme.fg( "muted", ` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`, ); const content = typeof message.content === "string" ? message.content : ""; // Remove only the summary line. The following Error line (when present) // is part of the actual result and must remain visible. const body = content.split("\n").slice(1).join("\n").trim(); if (expanded) { const md = new Markdown(`${body}`, 0, 0, getMarkdownTheme()); const container = new Text(header, 0, 0); return { render: (width: number) => [ ...container.render(width), ...md.render(width), ], invalidate: () => { container.invalidate(); md.invalidate(); }, }; } const previewLines = body.split("\n").slice(0, 8); let text = header; for (const line of previewLines) text += `\n${theme.fg("toolOutput", line)}`; if (body.split("\n").length > 8) text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`; return new Text(text, 0, 0); }, ); pi.registerEntryRenderer( "btw-result", (entry, { expanded }, theme) => { const data = entry.data; const failed = data?.status === "error"; const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■"); const header = `${icon} ` + theme.fg("accent", theme.bold(`by the way · ${data?.title ?? "?"}`)) + theme.fg( "muted", ` · ${failed ? "failed" : "answered"} · ${data?.id ?? "?"}`, ); const body = [ data?.errorText ? `Error: ${data.errorText}` : "", data?.answer ?? "(no answer)", ] .filter(Boolean) .join("\n\n"); if (expanded) { const md = new Markdown(body, 0, 0, getMarkdownTheme()); const container = new Text(header, 0, 0); return { render: (width: number) => [ ...container.render(width), ...md.render(width), ], invalidate: () => { container.invalidate(); md.invalidate(); }, }; } const lines = body.split("\n"); let text = header; for (const line of lines.slice(0, 8)) text += `\n${theme.fg("toolOutput", line)}`; if (lines.length > 8) text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`; return new Text(text, 0, 0); }, ); // --- Commands ----------------------------------------------------------- const runByTheWay = async (rawArgs: string, ctx: ExtensionCommandContext) => { if (ctx.mode !== "tui") { if (ctx.hasUI) ctx.ui.notify("by the way is only available in the TUI", "error"); return; } let prompt = rawArgs.trim(); if (!prompt) { const input = await ctx.ui.input("by the way", "Ask a one-off question…"); prompt = input?.trim() ?? ""; if (!prompt) return; } const manager = await getManager(); let snap: SubagentSnapshot; try { snap = await runTool( getRuntime(), manager.spawn("pi", { origin: "btw", prompt, title: deriveBtwTitle(prompt), cwd: ctx.cwd, parent: { parentCwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted(), inheritedModel: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined, inheritedThinkingLevel: pi.getThinkingLevel(), modelRegistry: ctx.modelRegistry, }, }), ); } catch (error) { ctx.ui.notify( error instanceof Error ? error.message : String(error), "error", ); return; } await openSubagentTakeover(ctx, manager.view, snap.id, { badge: "by the way", }); }; pi.registerCommand("btw", { description: "Ask a one-off side question while the main agent keeps working", handler: runByTheWay, }); pi.registerCommand("subagents", { description: "List, inspect, and take over subagents", handler: async (_args, ctx) => { if (ctx.mode !== "tui") { if (ctx.hasUI) ctx.ui.notify( "Subagent takeover is only available in the TUI", "error", ); return; } const manager = await getManager(); if (manager.view.size() === 0) { ctx.ui.notify( "No subagents yet. The agent spawns them with subagent_spawn.", "info", ); return; } await openSubagentPicker(ctx, manager.view); }, }); }