import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import type { Readable } from "node:stream"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; // Parent-side role of the herdr-subagents pi extension. Owns ONLY the bridge // between `helper watch` and the parent session: // - spawns `helper watch` once per session // - summarizes every tracked child as ONE status line rendered as a widget // above the input (ctx.ui.setWidget), recomputed on each change // - on a state the parent must act on — a finished turn (done, or idle right // after working), a dialog the child is stuck on (blocked), the resume from // one (working right after blocked), or a lost child (gone) — forwards a // compact wake (pi.sendMessage with triggerTurn) // // The extension holds NO herdr socket client and does NO extraction — it is a // thin bridge, and all herdr knowledge stays single-sourced in the helper. // The wake carries no payload: wake-then-collect. // No coalescing — native delivery semantics already prevent a burst from // derailing a turn. export const STATUS_KEY = "herdr-subagents"; export const WAKE_TYPE = "herdr-subagents:wake"; // `unknown` reads as `gone`: detection lost (CONTEXT.md / collect normalize). // A finished turn and a lost child both wake. So does `blocked`: the child is // stuck on a dialog and the parent is the only one watching that tab (ADR-0007). const WAKES = new Set(["done", "gone", "blocked"]); // Two wakes are defined by the transition rather than the status alone // (ADR-0008), because `prev` is all this bridge has — the sequence lives in the // helper: // - `idle` and `done` are herdr's SAME underlying state, `done` being the // unseen variant. A child whose tab has been seen finishes its turn as // `idle`, so an idle that FOLLOWS `working` is a finished turn; an idle that // follows anything else (a fresh child settling, an acknowledged `done`) is // not. // - `working` that FOLLOWS `blocked` is the child resuming — the parent asked // the human to answer a dialog and needs to know they did. const TRANSITION_WAKES: Array<{ from: string; to: string }> = [ { from: "working", to: "idle" }, { from: "blocked", to: "working" }, ]; // Statuses that drop a child from the live widget. `gone` (detection lost) // and `closed` (the parent ran `helper close`) both shrink the summary; only // `gone` wakes (it is terminal) — `closed` is deliberate and stays silent. const REMOVED = new Set(["gone", "closed"]); const moduleDir = dirname(fileURLToPath(import.meta.url)); // The helper binary ships at the package root (build/plan.ts emits // `herdr-helper` there). The dev loop overrides with HERDR_SUBAGENT_HELPER // (forwarded to children by spawn) so a session loading the extension from // source can point at the built helper. export function helperPath(): string { const override = process.env.HERDR_SUBAGENT_HELPER; if (override) return override; return join(packageRoot(moduleDir), "herdr-helper"); } // Walk up from `start` to the nearest directory holding a package.json — the // package root. Robust to compiled layouts (e.g. extension shipped under // /dist/extension/) where the extension file is not one level under the // root. Falls back to `start` if no package.json is found. export function packageRoot(start: string): string { let dir = start; for (;;) { if (existsSync(join(dir, "package.json"))) return dir; const parent = dirname(dir); if (parent === dir) return start; dir = parent; } } // The shape carried on every `helper watch` line: one per child status change. export interface ChildStatus { pane_id: string; label: string; status: string; } // The status surface. `ctx.ui` (ExtensionUIContext) satisfies this; a // test passes a plain spy. `setWidget(key, undefined)` clears the widget. export interface StatusSink { setWidget(key: string, content: string[] | undefined): void; } // Sends the terminal-state wake. Mirrors the slice of ExtensionAPI.sendMessage // the parent role uses, so the per-line logic is testable without a full pi. export type WakeSender = ( message: { customType: string; content: string; display: boolean }, options: { triggerTurn: boolean }, ) => void; // Tracked-children state for one session. `processLine` mutates it; the widget // is recomputed from it after every change. export interface ParentRoleState { children: Map; } export function createParentRoleState(): ParentRoleState { return { children: new Map() }; } // The status line summarizing every tracked child as `name: status`, ordered // by pane id. Returns undefined when there are no children so the caller can // clear the widget. Wrapped in a single-element array for setWidget. The name // falls back to the pane id when a child has no label. export function renderStatusLine(children: Map): string[] | undefined { if (children.size === 0) return undefined; const ordered = [...children.values()].sort((a, b) => a.pane_id < b.pane_id ? -1 : a.pane_id > b.pane_id ? 1 : 0, ); return [ordered.map((c) => `${c.label || c.pane_id}: ${c.status}`).join(" | ")]; } // One watch line → one status-line refresh, plus a wake on terminal-only // states. `gone` (detection lost) and `closed` (the parent ran `helper close`) // drop the child from the tracked set so the summary shrinks; only `gone` // wakes — `closed` is deliberate. Pipe-fitting: the spawn → line plumbing is // exercised by the dev loop, but this core is unit-tested directly. export function processLine( state: ParentRoleState, sink: StatusSink | undefined, sendWake: WakeSender, rawLine: string, ): void { let rec: ChildStatus; try { rec = JSON.parse(rawLine) as ChildStatus; } catch { return; } if (!rec.pane_id || !rec.status) return; // `unknown` reads as `gone`: detection lost (CONTEXT.md / collect normalize). // herdr never pushes `gone`; we derive it so a terminal wake still fires. const status = rec.status === "unknown" ? "gone" : rec.status; const prev = state.children.get(rec.pane_id); const child: ChildStatus = { pane_id: rec.pane_id, label: rec.label ?? prev?.label ?? "", status, }; if (REMOVED.has(status)) { state.children.delete(rec.pane_id); } else { state.children.set(rec.pane_id, child); } sink?.setWidget(STATUS_KEY, renderStatusLine(state.children)); const transitionWakes = TRANSITION_WAKES.some( (t) => t.to === status && prev?.status === t.from, ); if (!WAKES.has(status) && !transitionWakes) return; // The wake — terminal state only, compact, no payload. triggerTurn wakes an // idle parent; mid-turn it queues and lands at the turn boundary. // Wake-then-collect: the parent collects deliberately. sendWake( { customType: WAKE_TYPE, content: wakeContent(child), display: true }, { triggerTurn: true }, ); } // The wake carries no payload: a one-line nudge naming the child, its // pane id, and the `subagent` tool command to run next, so the parent knows // what to do without touching anything but the tool. The result is NOT here. // The helper CLI is never named — on pi the tool is the model's only surface. function wakeContent(rec: ChildStatus): string { const name = rec.label ? `"${rec.label}"` : rec.pane_id; if (rec.status === "blocked") { return `Child ${name} is blocked on a dialog. Use the subagent tool — read, pane_id ${rec.pane_id} — to see what it is asking, then tell the human to answer it in that tab.`; } if (rec.status === "working") { return `Child ${name} is no longer blocked and is working again.`; } return `Child ${name} reached ${rec.status}. Use the subagent tool — collect, pane_id ${rec.pane_id} — to read its result.`; } // The minimal surface the parent role reads from a spawned `helper watch`. // `spawn` with stdin ignored + piped stdout yields this shape. export interface WatchProcess { stdout: Readable & { setEncoding(encoding: string): void }; on(event: "error" | "exit", listener: () => void): unknown; kill(): void; } export function registerParentRole(pi: ExtensionAPI): () => void { const unsubs: Array<() => void> = []; const state = createParentRoleState(); const sendWake: WakeSender = (message, options) => { pi.sendMessage(message, options); }; // The watch data callback runs outside any handler and so has no `ctx`. The // status-widget sink lives on the handler context's `ctx.ui`, so capture it // once at session_start — stable for a single session. let ui: StatusSink | undefined; pi.on("session_start", (_event, ctx) => { ui = ctx.ui; }); let child: WatchProcess | null = null; let buffer = ""; let stopped = false; let restartTimer: NodeJS.Timeout | null = null; // A dead watcher clears the tracked set: the restarted watch re-seeds the // live children from the registry, and anything that closed while the // watcher was down must not linger on the widget. const resetState = () => { state.children.clear(); ui?.setWidget(STATUS_KEY, undefined); }; const scheduleRestart = () => { if (stopped || restartTimer) return; restartTimer = setTimeout(() => { restartTimer = null; start(); }, 2000); }; const start = () => { if (stopped || child) return; try { child = spawnWatch(); } catch { // A spawn failure must not crash the session — retry shortly. The wake's // durable backstop is `helper list` — the parent never loses a child. child = null; scheduleRestart(); return; } buffer = ""; child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { buffer += chunk; let nl: number; while ((nl = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1); if (line.trim() === "") continue; processLine(state, ui, sendWake, line); } }); // Errors/exit are swallowed: watch is best-effort telemetry. The registry // and `helper list` are the durable record; a dead watcher loses the live // status line but never loses a child. It IS restarted, though — a single // transient failure must not silence the widget for the whole session. child.on("error", () => { child = null; resetState(); scheduleRestart(); }); child.on("exit", () => { child = null; resetState(); scheduleRestart(); }); }; // Spawn once per session. A bare no-children registry is fine: watch stays // alive and quiet, the status line stays clear, and there is nothing to do // until a child is spawned. (New children appear on the next watch // resubscribe — the extension does not manage the subscription lifecycle; // the helper owns the registry.) start(); const stop = () => { stopped = true; if (restartTimer) clearTimeout(restartTimer); restartTimer = null; const c = child; child = null; if (c) { try { c.kill(); } catch { // already gone } } }; unsubs.push(stop); return () => { for (const fn of unsubs) { try { fn(); } catch { // a failing unsub must not abort the rest } } }; } function spawnWatch(): WatchProcess { return spawn(helperPath(), ["watch"], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, }) as unknown as WatchProcess; }