/** * Subagents v2 — AgentManager. * * Manages subagent lifecycle: spawn, wait, cancel, check, list. * Single source of ID creation, concurrency-limited queue (default 4). * * Follow-up delivery (exactly-once): * - On completion, manager marks ID as pending (does NOT deliver). * - wait/cancel consume the pending entry, preventing delivery. * - flushPendingFollowUps() delivers all unconsumed pending entries, * marks followUpDelivered only after actual send. * - The extension calls flushPendingFollowUps() when the parent agent * is idle (ctx.isIdle()) and on agent_settled. * * Waiter support: * - Multiple concurrent wait() calls use an array of waiter callbacks; * each call creates its own promise without overwriting others. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { HarnessKind, SubagentSpawnParams, SubagentRecord, SubagentEvent, SubagentHarness, } from "./types.ts"; import { resolveHarnessKind, resolveMaxConcurrent, resolveSpawnParams, getAllowedCwd, } from "./config.ts"; import { createHarness } from "./harness.ts"; import { SubagentEventBus, PendingDeliveryTracker, markFollowUpDelivered, createTranscriptWriter, ShutdownCoordinator, } from "./protocol.ts"; // ── Manager ─────────────────────────────────────────────────────────────────── export class AgentManager { private records = new Map(); private harnesses = new Map(); private queue: Array<{ id: string; harness: HarnessKind; params: SubagentSpawnParams; ctx: ExtensionContext; }> = []; private running = 0; private maxConcurrent: number; private events = new SubagentEventBus(); private pending = new PendingDeliveryTracker(); private shutdown = new ShutdownCoordinator(); private sessionId: string; private pi: ExtensionAPI | null = null; /** Most recent parent ctx for isIdle checks. */ private latestCtx: ExtensionContext | null = null; constructor(sessionId: string, maxConcurrent?: number) { this.sessionId = sessionId; this.maxConcurrent = maxConcurrent ?? resolveMaxConcurrent(); } /** Bind the ExtensionAPI for sendMessage follow-up delivery. */ bindApi(pi: ExtensionAPI): void { this.pi = pi; } /** Update the latest context reference (called from tool handlers). */ setLatestCtx(ctx: ExtensionContext): void { this.latestCtx = ctx; } // ── Public API ────────────────────────────────────────────────────────── /** * Spawn a subagent. Returns immediately with the record. * The manager generates the ID (single source of truth). */ spawn( params: SubagentSpawnParams, ctx: ExtensionContext, ): SubagentRecord { const effectiveParams = resolveSpawnParams(params); const harnessKind = resolveHarnessKind(effectiveParams.harness); const cwd = getAllowedCwd(effectiveParams.cwd, ctx.cwd); // Manager creates the ID — single source const id = generateId(); const record: SubagentRecord = { id, profile: effectiveParams.profile, harness: harnessKind, status: "queued", label: effectiveParams.label ?? `${harnessKind}-${id.slice(0, 8)}`, task: effectiveParams.task, cwd, model: effectiveParams.model, thinking: effectiveParams.thinking, startedAt: Date.now(), completedAt: null, toolCount: 0, output: "", followUpDelivered: false, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }, recentTools: [], }; // Transcript const transcript = createTranscriptWriter(cwd, id); if (transcript) { record.transcriptPath = transcript.path; transcript.writeEntry({ timestamp: Date.now(), type: "spawn", data: { profile: effectiveParams.profile, harness: harnessKind, task: effectiveParams.task, }, }); transcript.close(); } this.records.set(id, record); this.shutdown.register(record); // Manager is the single source for spawned event this.events.emit({ type: "subagent:spawned", id, harness: harnessKind, timestamp: Date.now(), record, }); // Queue or start const finalParams = { ...effectiveParams, cwd }; if (this.running >= this.maxConcurrent) { this.queue.push({ id, harness: harnessKind, params: finalParams, ctx }); } else { this.startAgent(id, harnessKind, finalParams, ctx); } return record; } /** Wait for a subagent to complete. Returns the final record. */ async wait(id: string): Promise { const record = this.records.get(id); if (!record) return undefined; // If already completed, consume pending and return immediately if (record.status !== "running" && record.status !== "queued") { this.pending.consume(id); return record; } // Still running — create a waiter promise return new Promise((resolve, reject) => { const waiter = { resolve: () => { this.pending.consume(id); resolve(this.records.get(id)!); }, reject: (err: Error) => { this.pending.consume(id); reject(err); }, }; if (!record._waiters) record._waiters = []; record._waiters.push(waiter); }); } /** Cancel a subagent. Accepts single ID or array. */ async cancel(idOrIds: string | string[]): Promise { const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; let anyCancelled = false; for (const id of ids) { // Consume pending delivery — prevents duplicate follow-up this.pending.consume(id); const record = this.records.get(id); if (!record) continue; if (record.status === "queued") { // Remove from queue this.queue = this.queue.filter((q) => q.id !== id); record.status = "cancelled"; record.completedAt = Date.now(); this.events.emit({ type: "subagent:cancelled", id, harness: record.harness, timestamp: record.completedAt, record, }); // Resolve waiters this.resolveWaiters(record); anyCancelled = true; continue; } if (record.status === "running") { record.abortController?.abort(); // Also cancel via harness const harness = this.harnesses.get(record.harness); if (harness) { try { await harness.cancel(id); } catch { /* best effort */ } } anyCancelled = true; continue; } } return anyCancelled; } /** Check a subagent's status. */ check(id: string): SubagentRecord | undefined { return this.records.get(id); } /** List all subagents (running, queued, and recently completed). */ list(): SubagentRecord[] { return [...this.records.values()].sort((a, b) => b.startedAt - a.startedAt); } /** Register an event listener. Returns unsubscribe function. */ onEvent(listener: (event: SubagentEvent) => void): () => void { return this.events.on(listener); } /** Set max concurrency. */ setMaxConcurrent(n: number): void { this.maxConcurrent = Math.max(1, n); this.drainQueue(); } /** Get max concurrency. */ getMaxConcurrent(): number { return this.maxConcurrent; } /** * Flush all pending follow-up deliveries that have not been consumed * by wait/cancel. Delivers via pi.sendMessage and marks delivered. * Safe to call multiple times — already-delivered records are skipped. */ flushPendingFollowUps(): void { if (!this.pi) return; const pendingIds = this.pending.getPendingIds(); for (const id of pendingIds) { const record = this.records.get(id); if (!record) continue; if (record.followUpDelivered) continue; try { this.deliverFollowUp(record); // Only consume/mark after sendMessage returns successfully. this.pending.consume(id); markFollowUpDelivered(record); } catch { // Keep pending so a later agent_settled can retry delivery. } } } /** Shutdown all subagents. */ async dispose(): Promise { await this.shutdown.shutdown(); for (const harness of this.harnesses.values()) { harness.dispose(); } this.harnesses.clear(); this.records.clear(); this.queue = []; this.pending.clear(); this.events.clear(); } // ── Internal ───────────────────────────────────────────────────────────── private async startAgent( id: string, harnessKind: HarnessKind, params: SubagentSpawnParams, ctx: ExtensionContext, ): Promise { this.running++; const record = this.records.get(id)!; let harness = this.harnesses.get(harnessKind); if (!harness) { harness = createHarness(harnessKind); this.harnesses.set(harnessKind, harness); } record.status = "running"; record.startedAt = Date.now(); record.abortController = new AbortController(); // Wire events from harness to our bus, with follow-up dedup const onHarnessEvent = (event: SubagentEvent) => { // Update local record from event data if (event.record) { const local = this.records.get(event.id); if (local) { local.status = event.record.status; local.toolCount = event.record.toolCount; local.recentTools = event.record.recentTools; local.usage = event.record.usage; local.output = event.record.output || local.output; local.error = event.record.error || local.error; local.completedAt = event.record.completedAt || local.completedAt; } } // On completion/failure/cancellation: mark pending, resolve waiters // Do NOT deliver follow-up immediately — let flushPendingFollowUps handle it if ( event.type === "subagent:completed" || event.type === "subagent:failed" || event.type === "subagent:cancelled" ) { const local = this.records.get(event.id); if (local) { // Mark as pending (for later flush), not delivered yet if (!local.followUpDelivered) { this.pending.mark(event.id); } // Flush immediately if parent is idle if (this.latestCtx?.isIdle()) { this.flushPendingFollowUps(); } } // Resolve/reject all waiters if (event.type === "subagent:failed") { this.resolveWaiters( this.records.get(event.id), new Error(event.error ?? "Subagent failed"), ); } else { this.resolveWaiters(this.records.get(event.id)); } } // Re-emit to external listeners this.events.emit(event); }; // Transcript const transcript = createTranscriptWriter(params.cwd ?? ctx.cwd, id); if (transcript && record.transcriptPath !== transcript.path) { record.transcriptPath = transcript.path; } if (transcript) { transcript.writeEntry({ timestamp: Date.now(), type: "start", data: {} }); transcript.close(); } try { await harness.spawn(id, params, ctx, onHarnessEvent, record.abortController.signal); } catch (err) { const local = this.records.get(id); if (local && local.status === "running") { local.status = "failed"; local.error = err instanceof Error ? err.message : String(err); local.completedAt = Date.now(); if (!local.followUpDelivered) { this.pending.mark(id); } this.resolveWaiters(local, new Error(local.error)); this.events.emit({ type: "subagent:failed", id, harness: harnessKind, timestamp: local.completedAt, record: local, error: local.error, }); // Flush immediately if parent is idle if (this.latestCtx?.isIdle()) { this.flushPendingFollowUps(); } } } finally { this.running--; this.drainQueue(); } } /** Resolve all waiters on a record. Pass err for failure, omit for success. */ private resolveWaiters(record?: SubagentRecord, err?: Error): void { if (!record?._waiters) return; const waiters = record._waiters; record._waiters = undefined; for (const w of waiters) { if (err) { try { w.reject(err); } catch { /* ignore */ } } else { try { w.resolve(); } catch { /* ignore */ } } } } private deliverFollowUp(record: SubagentRecord): void { if (!this.pi) return; const statusIcon = record.status === "completed" ? "✓" : record.status === "failed" ? "✗" : "✕"; const text = `${statusIcon} Subagent \`${record.label}\` [${record.harness}] ${record.status}` + (record.error ? `\nError: ${record.error}` : ""); this.pi.sendMessage( { customType: "subagent-followup", content: text, display: true, details: { record }, }, { deliverAs: "followUp", triggerTurn: true, }, ); } private drainQueue(): void { while (this.queue.length > 0 && this.running < this.maxConcurrent) { const next = this.queue.shift()!; const record = this.records.get(next.id); if (record && record.status === "queued") { this.startAgent(next.id, next.harness, next.params, next.ctx); } } } } // ── Helpers ────────────────────────────────────────────────────────────────── function generateId(): string { const bytes = new Uint8Array(8); crypto.getRandomValues(bytes); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); }