import { spawn, spawnSync } from 'node:child_process'; import { createReadStream, writeSync, unlinkSync, constants as fsConstants } from 'node:fs'; import { open as fsOpen, type FileHandle } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import type { ActivityFrame } from './activity-frame.js'; export interface ViewerHandle extends AsyncDisposable { /** Forward an ActivityFrame to the viewer. */ send: (frame: ActivityFrame) => void; /** * Update the banner pinned at the top of the viewer pane. The viewer * displays `specd · — type below to steer the sub-agent`. Used by * the loop driver to identify the current phase per cycle. */ setTitle: (title: string) => void; /** * Mark the run as finished. Sends a 'specd:done' control frame so the * viewer can render a completion footer. The pane stays open until the * user closes it or the FIFO timeout fires (whichever comes first). */ setDone: (summary?: string) => void; /** * Subscribe to user input typed into the viewer pane. Each call returns * an unsubscribe function. Multiple subscribers are supported but each * input is delivered to all of them. */ onInput: (handler: (text: string) => void) => () => void; /** * Subscribe to viewer-disconnect events (user closed the pane / pressed * Ctrl+D in it / it auto-exited). Used by the agent-runner's interactive * mode to know when to stop accepting follow-up prompts. */ onClose: (handler: () => void) => () => void; /** * Close both FIFOs. By default the viewer pane is left alive (it'll * auto-exit ~5min after seeing FIFO EOF as a crash safety-net; on a clean * run the parent calls `setDone` first and the user dismisses the pane * manually). Pass `{ kill: true }` to kill the pane immediately — useful * when the user explicitly aborted and doesn't want the partial output * lingering. */ close: (opts?: { kill?: boolean }) => Promise<void>; // [Symbol.asyncDispose] is inherited from AsyncDisposable so callers can // write `await using viewer = ...` and get spawn-time-default cleanup at // block exit. Most call sites still call close({ kill: ... }) explicitly // because they need to override `kill` per termination class — see loop.ts // and migrate.ts. await-using is for the cleanup-on-block-exit case where // the spawn-time killOnClose default is the right behavior. } interface ActiveViewer { handle: FileHandle; fifo: string; reverseFifo: string; paneId: string; killOnClose: boolean; } function noop() { // intentionally empty } // Idempotent: tolerates being called multiple times (e.g. from the explicit // close() path and again from the process-exit handler). Each step is wrapped // in its own try/catch so a no-op on one resource doesn't skip the rest, and // FileHandle.close() rejects with EBADF on a second call which we swallow. function cleanupOne(v: ActiveViewer) { try { void v.handle.close().catch(() => { // already closed / handle invalidated }); } catch { // already closed } try { unlinkSync(v.fifo); } catch { // already gone } try { unlinkSync(v.reverseFifo); } catch { // already gone } if (v.killOnClose && v.paneId) { try { spawnSync('tmux', ['kill-pane', '-t', v.paneId]); } catch { // pane already gone or tmux unavailable } } } function signalNumber(sig: 'SIGINT' | 'SIGTERM' | 'SIGHUP'): number { switch (sig) { case 'SIGHUP': return 1; case 'SIGINT': return 2; case 'SIGTERM': return 15; } } /** * Owns the set of live viewer panes plus the one-shot process-exit handlers * that clean them up on shutdown. Keeping this state inside a class (instead * of free-floating module variables) makes the lifecycle explicit: the * registry constructor wires the exit handlers, `spawnViewerPane` adds to * the active set, and `close()` (or process exit) removes from it. * * A single instance is exported as a thin function-style wrapper below so * existing callers don't need to learn a new shape. Don't construct this * class directly outside the module. */ class ViewerRegistry { private readonly active = new Set<ActiveViewer>(); constructor() { this.installExitHandlers(); } // Module-level guard so importing this file twice (HMR, vitest re-execution // under workers, etc.) doesn't double-register process listeners. ESM only // imports each module once so in practice this just makes the invariant // explicit. private static processHandlersInstalled = false; private installExitHandlers(): void { if (ViewerRegistry.processHandlersInstalled) return; ViewerRegistry.processHandlersInstalled = true; process.on('exit', () => { for (const v of this.active) cleanupOne(v); }); for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { process.on(sig, () => { if (process.listenerCount(sig) <= 1) process.exit(128 + signalNumber(sig)); }); } } /** * Spawn the renderer (viewer.mjs) in a side tmux pane and return a handle * that forwards ActivityFrames to it via a FIFO and receives user input * from the viewer's input box via a reverse FIFO. * * Returns null if not running inside tmux — the caller should fall back to * the widget-based progress display in that case. */ async spawnViewerPane(opts?: { title?: string; killOnClose?: boolean; }): Promise<ViewerHandle | null> { if (!process.env.TMUX) return null; // Default to false so the viewer pane lingers after a run completes: // (1) the agent's final events may still be draining when the parent closes // its end of the FIFO, and an immediate kill-pane drops them mid-render; // (2) it lets the user actually read the final state. // On clean exit the parent sends `specd:done` and the user dismisses the // pane manually. The viewer also auto-exits ~5min after seeing FIFO EOF as // a crash safety-net, so an orphaned pane can't linger forever. const killOnClose = opts?.killOnClose ?? false; const stamp = `${process.pid}-${Date.now()}`; const fifo = join(tmpdir(), `specd-viewer-${stamp}.fifo`); const reverseFifo = join(tmpdir(), `specd-viewer-${stamp}.rev.fifo`); const mk1 = spawnSync('mkfifo', ['-m', '600', fifo]); if (mk1.status !== 0) return null; const mk2 = spawnSync('mkfifo', ['-m', '600', reverseFifo]); if (mk2.status !== 0) { try { unlinkSync(fifo); } catch { // best-effort } return null; } const here = dirname(fileURLToPath(import.meta.url)); const viewer = resolve(here, 'viewer.mjs'); const cmd = `bash -c "node '${viewer}' '${fifo}' '${reverseFifo}'"`; const split = spawn('tmux', ['split-window', '-h', '-d', '-P', '-F', '#{pane_id}', cmd], { stdio: ['ignore', 'pipe', 'pipe'], }); let paneId = ''; split.stdout.on('data', (d: Buffer) => { paneId += d.toString(); }); const exit = await new Promise<number>((res) => { split.on('close', (code) => { res(code ?? 0); }); }); if (exit !== 0) { try { unlinkSync(fifo); } catch { // best-effort } try { unlinkSync(reverseFifo); } catch { // best-effort } return null; } paneId = paneId.trim(); // Open the forward FIFO for writing without blocking. A non-blocking // write-side open of a FIFO whose reader hasn't connected yet returns ENXIO, // so we poll with a small backoff until the viewer opens its read end. If // viewer.mjs crashes during init (theme load, pi-tui import, terminal-size // weirdness) the read end never opens — without a timeout the parent would // hang forever, so we cap the wait and fall back to widget mode (null). // // Two-step open: first an O_NONBLOCK probe to detect the reader (or time // out), then a normal blocking re-open so subsequent writeSync calls retain // their original blocking semantics (the alternative would be EAGAIN errors // whenever the FIFO buffer briefly backs up). const openDeadlineMs = 3000; const openPollMs = 50; const startedAt = Date.now(); let probeHandle: FileHandle | null = null; let openFatal = false; while (Date.now() - startedAt < openDeadlineMs) { try { probeHandle = await fsOpen(fifo, fsConstants.O_WRONLY | fsConstants.O_NONBLOCK); break; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENXIO') { await delay(openPollMs); continue; } // Any other error is fatal — can't recover. openFatal = true; break; } } let fileHandle: FileHandle | null = null; if (probeHandle && !openFatal) { // Reader is now attached. Close the non-blocking probe and reopen with // standard blocking write semantics so writeSync behaves as before. try { await probeHandle.close(); } catch { // ignore } try { fileHandle = await fsOpen(fifo, 'w'); } catch { fileHandle = null; } } if (!fileHandle) { // Viewer never connected (or the second open raced with a viewer crash). // Kill the pane (if any) and clean up the FIFOs; the caller will see null // and fall back to the widget progress display. if (paneId) { try { spawnSync('tmux', ['kill-pane', '-t', paneId]); } catch { // pane already gone } } try { unlinkSync(fifo); } catch { // best-effort } try { unlinkSync(reverseFifo); } catch { // best-effort } return null; } const fd = fileHandle.fd; const handle: ActiveViewer = { handle: fileHandle, fifo, reverseFifo, paneId, killOnClose }; this.active.add(handle); // Open the reverse FIFO for reading. Each line is a JSON-encoded user message. const inputHandlers = new Set<(text: string) => void>(); const closeHandlers = new Set<() => void>(); let viewerClosed = false; const fireClose = () => { if (viewerClosed) return; viewerClosed = true; // Snapshot so a handler that unsubscribes itself (or registers a new one) // doesn't perturb the in-flight iteration. for (const h of [...closeHandlers]) { try { h(); } catch { // ignore handler errors } } }; // Decode as UTF-8 here so multi-byte sequences split across chunk // boundaries are reassembled by Node's StringDecoder rather than corrupted // by per-chunk Buffer.toString() calls (which would yield invalid JSON the // catch below silently dropped). const reverseStream = createReadStream(reverseFifo, { encoding: 'utf-8' }); let inputBuffer = ''; reverseStream.on('data', (chunk: string) => { inputBuffer += chunk; let nl: number; while ((nl = inputBuffer.indexOf('\n')) !== -1) { const line = inputBuffer.slice(0, nl); inputBuffer = inputBuffer.slice(nl + 1); if (!line) continue; let text: string; try { const parsed: unknown = JSON.parse(line); if (typeof parsed !== 'string') continue; text = parsed; } catch { continue; } // Snapshot so a handler that unsubscribes itself doesn't perturb the // iteration mid-flight. for (const h of [...inputHandlers]) h(text); } }); reverseStream.on('end', fireClose); reverseStream.on('close', fireClose); reverseStream.on('error', () => { fireClose(); }); let closed = false; const writeLine = (payload: unknown) => { if (closed) return; try { writeSync(fd, `${JSON.stringify(payload)}\n`); } catch { closed = true; } }; // Push the spawn-time title to the viewer immediately so the banner // identifies the pane before any frames arrive. if (opts?.title) { writeLine({ kind: 'control', subtype: 'specd:title', title: opts.title }); } const closeHandle = async (closeOpts?: { kill?: boolean }) => { if (closed) return; closed = true; reverseStream.destroy(); this.active.delete(handle); // The handle's killOnClose was the spawn-time default; an explicit // override here wins so the caller can decide per-call. cleanupOne({ ...handle, killOnClose: closeOpts?.kill ?? handle.killOnClose }); }; return { send: (frame) => { writeLine(frame); }, setTitle: (title) => { writeLine({ kind: 'control', subtype: 'specd:title', title }); }, setDone: (summary) => { writeLine({ kind: 'control', subtype: 'specd:done', summary }); }, onInput: (handler) => { inputHandlers.add(handler); return () => inputHandlers.delete(handler); }, onClose: (handler) => { if (viewerClosed) { // Already closed — fire immediately on next tick. Wrap in try/catch // so a throwing handler doesn't surface as an unhandled rejection in // the microtask queue (matches the in-flight fireClose contract). queueMicrotask(() => { try { handler(); } catch { // ignore handler errors } }); // No-op unsubscribe: the close already fired, nothing to detach. return noop; } closeHandlers.add(handler); return () => closeHandlers.delete(handler); }, close: closeHandle, // AsyncDisposable: lets callers write `await using viewer = ...` and // get spawn-time-default cleanup at block exit. Callers that need to // override `kill` per termination class still call close({ kill: ... }) // explicitly — see loop.ts and migrate.ts. [Symbol.asyncDispose]: async () => { await closeHandle(); }, }; } } const registry = new ViewerRegistry(); /** * Thin function-style wrapper around the module-level ViewerRegistry. Kept * as an exported function so callers (loop.ts, migrate.ts, run-one-shot.ts) * don't need to know about the registry instance. */ export async function spawnViewerPane(opts?: { title?: string; killOnClose?: boolean; }): Promise<ViewerHandle | null> { return registry.spawnViewerPane(opts); }