/** * Subagents v2 — Pi harness. * * Spawns subagents via createAgentSession with modelRegistry from ctx. * Supports thinking levels, model provider/id resolution via ctx.modelRegistry, * and safe tool allowlist. Always disposes in finally. * Does NOT emit subagent:spawned — the manager owns that event. */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { SubagentHarness, SubagentSpawnParams, SubagentRecord, SubagentEvent, } from "./types.ts"; import { resolveModel, resolveThinking } from "./config.ts"; import { normalizeToolEvent } from "./protocol.ts"; /** Safe tool allowlist for child agent sessions. */ const CHILD_TOOL_ALLOWLIST = ["read", "bash", "edit", "write"]; export class PiSubagentHarness implements SubagentHarness { readonly kind = "pi" as const; async spawn( id: string, params: SubagentSpawnParams, ctx: ExtensionContext, onEvent: (event: SubagentEvent) => void, signal?: AbortSignal, ): Promise { const cwd = params.cwd ?? ctx.cwd; // Resolve model: first explicit provider/id, then inherit parent model const modelStr = resolveModel("pi", params.model); let model: any = ctx.model; // inherit parent model by default if (modelStr) { const [provider, modelId] = modelStr.split("/"); if (provider && modelId) { const found = ctx.modelRegistry.find(provider, modelId); if (!found) { throw new Error(`Unknown Pi subagent model: ${modelStr}`); } model = found; } } const thinking = resolveThinking("pi", params.thinking) ?? "medium"; const validLevels = [ "off", "minimal", "low", "medium", "high", "xhigh", "max", ]; const thinkingLevel = validLevels.includes(thinking) ? thinking : "medium"; const startedAt = Date.now(); const record: SubagentRecord = { id, harness: "pi", status: "running", label: params.label ?? `pi-${id.slice(0, 8)}`, task: params.task, cwd, model: modelStr ?? `${model?.provider}/${model?.id}`, thinking: thinkingLevel, startedAt, completedAt: null, toolCount: 0, output: "", followUpDelivered: false, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, }, recentTools: [], }; // Manager emits spawned; harness emits started onEvent({ type: "subagent:started", id, harness: "pi", timestamp: startedAt, record, }); let session: any = undefined; let abortHandler: (() => void) | undefined; try { // Dynamic import to avoid requiring pi-coding-agent at module load const { createAgentSession } = await import( "@earendil-works/pi-coding-agent" ); const result = await createAgentSession({ cwd, model, thinkingLevel: thinkingLevel as any, tools: CHILD_TOOL_ALLOWLIST, }); session = result.session; if (signal) { abortHandler = () => { void session?.abort?.().catch?.(() => {}); }; signal.addEventListener("abort", abortHandler, { once: true }); if (signal.aborted) abortHandler(); } let output = ""; // Subscribe to session events for tool tracking const unsubscribe = session.subscribe((evt: any) => { if (signal?.aborted) return; if (evt.type === "tool_execution_start") { const normalized = normalizeToolEvent({ toolName: evt.toolName, args: evt.args, status: "start", toolCallId: evt.toolCallId, }); record.toolCount++; record.recentTools.push(normalized); if (record.recentTools.length > 20) record.recentTools.shift(); onEvent({ type: "subagent:progress", id, harness: "pi", timestamp: Date.now(), record, }); } if (evt.type === "tool_execution_end") { const match = findLastRunning(record.recentTools); if (match) match.status = "done"; onEvent({ type: "subagent:progress", id, harness: "pi", timestamp: Date.now(), record, }); } if ( evt.type === "message_end" && evt.message?.role === "assistant" ) { record.usage.turns++; const u = evt.message.usage; if (u) { record.usage.input += u.input || 0; record.usage.output += u.output || 0; record.usage.cacheRead += u.cacheRead || 0; record.usage.cacheWrite += u.cacheWrite || 0; record.usage.cost += u.cost?.total || 0; } const text = extractText(evt.message.content); if (text) output = text; } if (evt.type === "agent_settled") { const lastText = session.getLastAssistantText?.(); if (lastText) output = lastText; } }); // Send the task prompt await session.prompt(params.task); // Wait for agent to settle (with abort support) if (signal?.aborted) { record.output = output || "(cancelled before completion)"; record.completedAt = Date.now(); record.status = "cancelled"; onEvent({ type: "subagent:cancelled", id, harness: "pi", timestamp: record.completedAt, record, }); unsubscribe(); return; } const settlePromise = session.waitForIdle(); const abortPromise = new Promise((_, reject) => { if (signal?.aborted) { reject(new Error("Aborted")); return; } const onAbort = () => reject(new Error("Aborted")); signal?.addEventListener("abort", onAbort, { once: true }); }); try { await Promise.race([settlePromise, abortPromise]); } catch { // Aborted or failed const lastText = session.getLastAssistantText?.(); if (lastText) output = lastText; } // Get final output const lastText = session.getLastAssistantText?.(); if (lastText) output = lastText; unsubscribe(); record.output = output || "(no output)"; record.completedAt = Date.now(); if (signal?.aborted) { record.status = "cancelled"; onEvent({ type: "subagent:cancelled", id, harness: "pi", timestamp: record.completedAt, record, }); } else { record.status = "completed"; onEvent({ type: "subagent:completed", id, harness: "pi", timestamp: record.completedAt, record, }); } } catch (err) { record.completedAt = Date.now(); if (signal?.aborted) { record.status = "cancelled"; record.error = "Cancelled"; onEvent({ type: "subagent:cancelled", id, harness: "pi", timestamp: record.completedAt, record, }); } else { record.status = "failed"; record.error = err instanceof Error ? err.message : String(err); onEvent({ type: "subagent:failed", id, harness: "pi", timestamp: record.completedAt, record, error: record.error, }); } } finally { if (abortHandler) signal?.removeEventListener("abort", abortHandler); // Always dispose session in finally try { session?.dispose(); } catch { /* ignore */ } } } async cancel(_id: string): Promise { // Abort is handled via the signal in spawn() } dispose(): void { // No shared state to dispose in pi harness } } // ── Helpers ────────────────────────────────────────────────────────────────── function extractText(content: unknown): string { if (!content) return ""; if (typeof content === "string") return content; if (Array.isArray(content)) { return (content as any[]) .filter((c: any) => c.type === "text") .map((c: any) => c.text) .join("\n"); } return ""; } function findLastRunning( tools: Array<{ status: "running" | "done" }>, ): { status: "running" | "done" } | undefined { for (let i = tools.length - 1; i >= 0; i--) { if (tools[i].status === "running") return tools[i]; } return undefined; }