/** * pi-subagents event-bus client (protocol v2). * * Wire format (@clanker-code/pi-subagents cross-extension-rpc): * emit `subagents:rpc:` with `{ requestId, ... }` * reply `subagents:rpc::reply:${requestId}` with * `{ success: true, data?: T } | { success: false, error: string }` * * Completion: `subagents:completed` / `subagents:failed` with `{ id, ... }`. */ export interface EventBus { on(event: string, handler: (payload: any) => void): () => void; emit(event: string, payload: any): void; } export interface SpawnOptions { type: string; prompt: string; description?: string; model?: string; maxTurns?: number; isBackground?: boolean; /** * When true, ask pi-subagents to fork/inherit the parent conversation into the * agent so it sees chat history (inherit_context / inheritContext). * Default on the wire is host-dependent; callers should set explicitly. */ inheritContext?: boolean; /** * Optional absolute working directory for the child agent (pi-subagents * `options.cwd`). Tools operate there; parent project config still loads * from the parent session. Used to isolate planner writes from the * canonical goal store. */ cwd?: string; } export interface SpawnResult { id: string; } export interface WaitResult { id: string; status: string; result?: string; error?: string; } function requestId(): string { return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; } function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`${label} timed out after ${timeoutMs}ms`)); }, timeoutMs); promise.then( (v) => { clearTimeout(timer); resolve(v); }, (err) => { clearTimeout(timer); reject(err); }, ); }); } /** Ping subagents. true if a successful reply arrives. */ export async function pingSubagents(bus: EventBus, timeoutMs = 2000): Promise { const id = requestId(); try { await new Promise((resolve, reject) => { const timer = setTimeout(() => { off(); reject(new Error("timeout")); }, timeoutMs); const off = bus.on(`subagents:rpc:ping:reply:${id}`, (payload: any) => { clearTimeout(timer); off(); if (payload?.success === false) { reject(new Error(payload?.error ?? "ping failed")); return; } resolve(); }); bus.emit("subagents:rpc:ping", { requestId: id }); }); return true; } catch { return false; } } /** Spawn a subagent. Resolves with assigned agent id. */ export async function spawnSubagent( bus: EventBus, opts: SpawnOptions, timeoutMs = 30_000, ): Promise { const id = requestId(); const replyChannel = `subagents:rpc:spawn:reply:${id}`; return withTimeout( new Promise((resolve, reject) => { const off = bus.on(replyChannel, (payload: any) => { off(); if (payload?.success === false) { reject(new Error(payload?.error ?? "spawn failed")); return; } const data = payload?.data ?? payload; const agentId = data?.id ?? data?.agentId; if (!agentId || typeof agentId !== "string") { reject(new Error("spawn reply missing id")); return; } resolve({ id: agentId }); }); const options: Record = { description: opts.description, model: opts.model, maxTurns: opts.maxTurns, isBackground: opts.isBackground ?? true, }; // Only include when set so older hosts ignore unknown keys cleanly. if (opts.inheritContext !== undefined) { options.inheritContext = opts.inheritContext; // Snake_case alias for tool-param / frontmatter style hosts. options.inherit_context = opts.inheritContext; } if (opts.cwd !== undefined) { options.cwd = opts.cwd; } bus.emit("subagents:rpc:spawn", { requestId: id, type: opts.type, prompt: opts.prompt, options, }); }), timeoutMs, "subagents spawn", ); } /** Wait for subagent completed/failed. Unregisters listeners on settle or timeout. */ export async function waitSubagent( bus: EventBus, agentId: string, timeoutMs = 600_000, ): Promise { return new Promise((resolve, reject) => { let settled = false; const offs: Array<() => void> = []; const cleanup = () => { for (const off of offs) { try { off(); } catch { /* non-fatal */ } } offs.length = 0; }; const settle = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); cleanup(); fn(); }; const timer = setTimeout(() => { settle(() => { reject(new Error(`wait subagent ${agentId} timed out after ${timeoutMs}ms`)); }); }, Math.max(1, timeoutMs)); offs.push( bus.on("subagents:completed", (payload: any) => { if (payload?.id !== agentId) return; settle(() => { resolve({ id: agentId, status: String(payload.status ?? "completed"), result: typeof payload?.result === "string" ? payload.result : payload?.result != null ? String(payload.result) : undefined, }); }); }), ); offs.push( bus.on("subagents:failed", (payload: any) => { if (payload?.id !== agentId) return; settle(() => { resolve({ id: agentId, status: "failed", error: typeof payload?.error === "string" ? payload.error : payload?.error != null ? String(payload.error) : "failed", result: typeof payload?.result === "string" ? payload.result : undefined, }); }); }), ); }); } /** Request stop of a running subagent. */ export async function stopSubagent(bus: EventBus, agentId: string): Promise { const id = requestId(); const replyChannel = `subagents:rpc:stop:reply:${id}`; await new Promise((resolve) => { const timer = setTimeout(() => { off(); resolve(); }, 1500); const off = bus.on(replyChannel, () => { clearTimeout(timer); off(); resolve(); }); bus.emit("subagents:rpc:stop", { requestId: id, agentId }); }); } /** In-memory EventBus for tests. */ export function createMemoryBus(): EventBus { const handlers = new Map void>>(); return { on(event, handler) { let set = handlers.get(event); if (!set) { set = new Set(); handlers.set(event, set); } set.add(handler); return () => { set!.delete(handler); }; }, emit(event, payload) { const set = handlers.get(event); if (!set) return; for (const h of [...set]) h(payload); }, }; } /** * Minimal mock RPC server for tests (protocol v2 envelopes). * Supports sequential multi-spawn via spawnIds / completeResult callback. */ export function installMockSubagentsRpc( bus: EventBus, opts?: { spawnId?: string; /** Prefer over spawnId when spawning a panel of N agents. */ spawnIds?: string[]; onSpawn?: (p: { type: string; prompt: string; index: number; options?: Record; }) => void; autoComplete?: boolean; /** Static result, or per-spawn index → result text. */ completeResult?: string | ((index: number) => string); planMarkdown?: string; /** Delay before completed event (ms). Default 15. */ completeDelayMs?: number; }, ): void { let spawnIndex = 0; bus.on("subagents:rpc:ping", (payload: any) => { const rid = payload?.requestId; if (!rid) return; bus.emit(`subagents:rpc:ping:reply:${rid}`, { success: true, data: { version: 2 }, }); }); bus.on("subagents:rpc:spawn", (payload: any) => { const rid = payload?.requestId; if (!rid) return; const index = spawnIndex++; opts?.onSpawn?.({ type: payload.type, prompt: payload.prompt, index, options: payload.options, }); const agentId = opts?.spawnIds?.[index] ?? opts?.spawnId ?? `agent_${Math.random().toString(36).slice(2, 8)}`; bus.emit(`subagents:rpc:spawn:reply:${rid}`, { success: true, data: { id: agentId }, }); if (opts?.autoComplete !== false) { // Delay so callers can subscribe to completed/failed after spawn resolves. const delay = opts?.completeDelayMs ?? 15; setTimeout(() => { let result: string; if (typeof opts?.completeResult === "function") { result = opts.completeResult(index); } else { result = opts?.completeResult ?? opts?.planMarkdown ?? "Done"; } bus.emit("subagents:completed", { id: agentId, status: "completed", result, }); }, delay); } }); bus.on("subagents:rpc:stop", (payload: any) => { const rid = payload?.requestId; if (!rid) return; bus.emit(`subagents:rpc:stop:reply:${rid}`, { success: true }); }); }