/** * Subagents v2 — Protocol layer. * * Event bus, pending delivery tracking for follow-up dedup, * transcript file writing, and shutdown coordination. */ import * as fs from "node:fs"; import * as path from "node:path"; import type { SubagentEvent, SubagentEventListener, SubagentRecord } from "./types.ts"; // ── Event bus ───────────────────────────────────────────────────────────────── export class SubagentEventBus { private listeners = new Set(); on(listener: SubagentEventListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } emit(event: SubagentEvent): void { for (const listener of this.listeners) { try { listener(event); } catch { /* ignore listener errors */ } } } clear(): void { this.listeners.clear(); } } // ── Pending delivery (dedup) ────────────────────────────────────────────────── /** * Tracks which subagent IDs have a pending follow-up delivery. * * Flow: * 1. Subagent completes → manager adds ID to this set * 2. Manager calls pi.sendMessage(..., {deliverAs:'followUp',triggerTurn:true}) * 3. When the model processes the follow-up and calls subagent_wait/cancel, * the manager consumes the pending delivery (removes from set), preventing * duplicate delivery. * 4. If neither wait nor cancel is called within a timeout, the follow-up * remains as a one-shot notification. */ export class PendingDeliveryTracker { private pending = new Set(); /** Mark an agent as having a pending follow-up. */ mark(agentId: string): void { this.pending.add(agentId); } /** * Consume the pending delivery for an agent. * Returns true if there was a pending delivery (i.e., this is the first * consumption). Returns false if already consumed or never pending. */ consume(agentId: string): boolean { if (this.pending.has(agentId)) { this.pending.delete(agentId); return true; } return false; } /** Check if an agent has a pending delivery. */ has(agentId: string): boolean { return this.pending.has(agentId); } /** Get all pending IDs (snapshot for flush iteration). */ getPendingIds(): string[] { return [...this.pending]; } /** Clear all pending deliveries. */ clear(): void { this.pending.clear(); } get size(): number { return this.pending.size; } } // ── Follow-up dedup on records ──────────────────────────────────────────────── export function markFollowUpDelivered(record: SubagentRecord): boolean { if (record.followUpDelivered) return false; record.followUpDelivered = true; return true; } // ── Transcript ──────────────────────────────────────────────────────────────── export interface TranscriptWriter { readonly path: string; writeEntry(entry: TranscriptEntry): void; close(): Promise; } export interface TranscriptEntry { timestamp: number; type: | "spawn" | "start" | "tool_start" | "tool_end" | "message" | "complete" | "error" | "cancel"; data: Record; } export function createTranscriptWriter( cwd: string, agentId: string, ): TranscriptWriter | null { try { const dir = path.join(cwd, ".pi", "output"); fs.mkdirSync(dir, { recursive: true }); const filePath = path.join(dir, `subagent-${agentId}.jsonl`); const stream = fs.createWriteStream(filePath, { flags: "a" }); return { path: filePath, writeEntry(entry: TranscriptEntry) { try { stream.write(JSON.stringify(entry) + "\n"); } catch { /* ignore write errors */ } }, close() { return new Promise((resolve) => { try { stream.end(() => resolve()); } catch { resolve(); } }); }, }; } catch { return null; } } // ── Shutdown coordinator ────────────────────────────────────────────────────── export class ShutdownCoordinator { private records = new Map(); private shuttingDown = false; register(record: SubagentRecord): void { this.records.set(record.id, record); } unregister(id: string): void { this.records.delete(id); } async shutdown(): Promise { if (this.shuttingDown) return; this.shuttingDown = true; const cancels: Promise[] = []; for (const record of this.records.values()) { if (record.status === "running" || record.status === "queued") { record.abortController?.abort(); } } await Promise.allSettled(cancels); this.records.clear(); } get isShuttingDown(): boolean { return this.shuttingDown; } } // ── Tool event normalization ───────────────────────────────────────────────── export interface RawToolEvent { toolName?: string; tool?: string; args?: Record | string; status?: "running" | "done" | "start" | "end" | "update"; toolCallId?: string; timestamp?: number; } export function normalizeToolEvent(raw: RawToolEvent): { tool: string; args: string; status: "running" | "done"; timestamp: number; } { const tool = raw.toolName ?? raw.tool ?? "unknown"; const argsStr = typeof raw.args === "string" ? raw.args : safeStringify(raw.args ?? {}); const status: "running" | "done" = raw.status === "end" || raw.status === "done" ? "done" : "running"; return { tool, args: argsStr, status, timestamp: raw.timestamp ?? Date.now(), }; } function safeStringify(obj: Record): string { try { const s = JSON.stringify(obj); return s.length > 200 ? s.slice(0, 200) + "…" : s; } catch { return "(args)"; } }