# Launcher Live-Output Pane Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let a user opt-in "observe" an externally launched AI agent (Claude Code, Cursor, Codex…) and watch its live stdout/stderr in the dock, without breaking the default detached/survives-restart launch. **Architecture:** A new piped-stdio `spawnObserved` seam in `tool-launcher.ts` feeds a bounded per-pid `ToolOutputBuffer` (ring buffer, ANSI-stripped). A new SSE route `GET /api/tools/stream?pid=` (mirroring `chat.ts`'s `reply.hijack()`) replays the tail then live-tails. The dock's existing **Running** badge becomes a progressive-disclosure toggle for a `ToolOutputPane`; a single ⌘K catalog entry deep-links to the launcher's watch mode. Observed pids are in-memory only (never persisted) so the #2 restart guarantee stays honest. **Tech Stack:** TypeScript, Node `child_process`, Fastify (sidecar), React 19 + Vite, EventSource SSE, Vitest. ## Global Constraints - **Capture model = piped stdio.** No `node-pty`, no native deps, no `xterm.js`, no input-send. - **Default launch path unchanged.** `observe` is opt-in; `observe:false`/absent ⇒ today's `spawnDetached`. - **Observed pids are in-memory only** — `ToolProcessTracker.persist()` must exclude them; the output buffer is in-memory. - **Bounds:** ring buffer `MAX_LINES = 2000`, `MAX_BYTES = 256*1024` per pid; evict an exited entry after `EVICT_GRACE_MS = 60000`. - **Output is display-only** — never auto-ingested; ANSI stripped on ingest. - **DI seams stay hermetic** — every spawn/timer is injectable so tests never launch a real process or wait on a real clock. - **Gates:** `npx tsc --noEmit` 0 across agent/server/web; new units RED→GREEN; existing launcher suites stay green. - **SSE auth:** EventSource cannot send headers — `/api/tools/stream` MUST be added to `SSE_QUERY_TOKEN_PATHS`. --- ### Task 1: `spawnObserved` seam + `launchTool` observe option **Files:** - Modify: `packages/agent/src/tool-launcher.ts` - Test: `packages/agent/tests/tool-launcher.test.ts` **Interfaces:** - Produces: `ObservedHandle { onData(cb:(chunk:string)=>void):void; onExit(cb:(code:number|null)=>void):void }`; `ToolLauncherDeps.spawnObserved?`; `LaunchOptions.observe?: boolean`; `LaunchResult.output?: ObservedHandle`. - [ ] **Step 1: Write the failing test** — append to `packages/agent/tests/tool-launcher.test.ts`: ```ts import { launchTool, type ObservedHandle } from '../src/tool-launcher'; describe('launchTool observe mode', () => { it('uses spawnObserved and returns its handle when observe:true', () => { let dataCb: ((c: string) => void) | null = null; const handle: ObservedHandle = { onData: (cb) => { dataCb = cb; }, onExit: () => {}, }; const spawnObserved = vi.fn(() => ({ pid: 4242, handle })); const spawnDetached = vi.fn(() => ({ pid: 1 })); const res = launchTool({ id: 'claude-code', installedPath: '/usr/bin/claude', observe: true, deps: { spawnObserved, spawnDetached }, }); expect(spawnObserved).toHaveBeenCalledTimes(1); expect(spawnDetached).not.toHaveBeenCalled(); expect(res.ok).toBe(true); expect(res.pid).toBe(4242); expect(res.output).toBe(handle); expect(typeof dataCb).toBe('object'); // null until onData wired by consumer }); it('uses spawnDetached and omits output when observe is absent', () => { const spawnObserved = vi.fn(() => ({ pid: 9, handle: { onData() {}, onExit() {} } })); const spawnDetached = vi.fn(() => ({ pid: 7 })); const res = launchTool({ id: 'claude-code', installedPath: '/usr/bin/claude', deps: { spawnObserved, spawnDetached }, }); expect(spawnDetached).toHaveBeenCalledTimes(1); expect(spawnObserved).not.toHaveBeenCalled(); expect(res.output).toBeUndefined(); }); it('reports observe spawn failure as ok:false', () => { const res = launchTool({ id: 'claude-code', installedPath: '/usr/bin/claude', observe: true, deps: { spawnObserved: () => ({ pid: null, error: 'boom' }) }, }); expect(res.ok).toBe(false); expect(res.error).toBe('boom'); expect(res.output).toBeUndefined(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/agent/tests/tool-launcher.test.ts -t "observe"` Expected: FAIL — `observe`/`output`/`spawnObserved` not on the types. - [ ] **Step 3: Implement** in `packages/agent/src/tool-launcher.ts`: After the `import { spawn, execFile } ...` block, add the handle type near the deps interface: ```ts /** Minimal, test-injectable view of a live observed process. */ export interface ObservedHandle { /** Subscribe to decoded stdout+stderr text chunks. */ onData(cb: (chunk: string) => void): void; /** Fired once when the process exits. `code` is null on signal-kill. */ onExit(cb: (code: number | null) => void): void; } ``` In `interface ToolLauncherDeps`, add: ```ts /** * Piped-stdio spawn for OBSERVED launches. Production uses * `child_process.spawn` with `stdio:['ignore','pipe','pipe']` and NO * `unref` — the sidecar holds the pipes so output can stream, which * means the child is tethered to the sidecar (dies on restart). Returns * the pid + an abstract ObservedHandle (or { error }). */ spawnObserved?: ( binary: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv }, ) => { pid: number | null; error?: string; handle?: ObservedHandle }; ``` Add the default impl after `defaultSpawnDetached`: ```ts function defaultSpawnObserved( binary: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv }, ): { pid: number | null; error?: string; handle?: ObservedHandle } { try { const child = spawn(binary, args, { cwd: options.cwd, env: { ...process.env, ...(options.env ?? {}) }, stdio: ['ignore', 'pipe', 'pipe'], // NOT detached, NOT unref'd: observation requires holding the pipes. }); if (child.pid == null) return { pid: null, error: 'spawn returned no pid' }; const handle: ObservedHandle = { onData(cb) { child.stdout?.on('data', (d: Buffer) => cb(d.toString('utf8'))); child.stderr?.on('data', (d: Buffer) => cb(d.toString('utf8'))); }, onExit(cb) { child.on('exit', (code) => cb(code)); }, }; return { pid: child.pid, handle }; } catch (err) { return { pid: null, error: err instanceof Error ? err.message : String(err) }; } } ``` In `interface ResolvedDeps` add `spawnObserved: NonNullable;` and in `resolveDeps` add `spawnObserved: opts.spawnObserved ?? defaultSpawnObserved,`. In `interface LaunchOptions` add (near `signalEmit`): ```ts /** * When true, launch in OBSERVED mode (piped stdio) so stdout/stderr can be * streamed to the dock. The process is tethered to the sidecar (dies on * restart) and is tracked in-memory only. Default (false/absent) is the * detached, survives-restart launch. */ observe?: boolean; ``` In `interface LaunchResult` add `output?: ObservedHandle;`. In `launchTool`, after the `env` is built and before the existing `spawnDetached` call, branch: ```ts if (opts.observe) { const { pid, error, handle } = deps.spawnObserved(opts.installedPath, args, { cwd: opts.cwd, env, }); return { ok: pid != null && !error, pid, executed: { binary: opts.installedPath, args, cwd: opts.cwd }, error, ...(handle ? { output: handle } : {}), }; } ``` (Leave the existing detached `spawnDetached(...)` return as the else path.) - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/agent/tests/tool-launcher.test.ts` Expected: PASS (new observe tests + all existing launcher tests). - [ ] **Step 5: Typecheck + Commit** ```bash npx tsc --noEmit --project packages/agent/tsconfig.json git add packages/agent/src/tool-launcher.ts packages/agent/tests/tool-launcher.test.ts git commit -m "feat(agent): spawnObserved seam + launchTool observe mode" ``` --- ### Task 2: `ToolOutputBuffer` ring buffer **Files:** - Create: `packages/agent/src/tool-output-buffer.ts` - Modify: `packages/agent/src/index.ts` (export) - Test: `packages/agent/tests/tool-output-buffer.test.ts` **Interfaces:** - Consumes: `ObservedHandle` (Task 1). - Produces: `class ToolOutputBuffer` with `attach(pid:number, handle:ObservedHandle):void`, `has(pid:number):boolean`, `getTail(pid:number):OutputTail|null`, `subscribe(pid:number, onLine:(line:string)=>void, onExit:(code:number|null)=>void):()=>void`; `OutputTail { lines:string[]; exited:boolean; exitCode:number|null }`; `stripAnsi(s:string):string`. - [ ] **Step 1: Write the failing test** — `packages/agent/tests/tool-output-buffer.test.ts`: ```ts import { describe, it, expect, vi } from 'vitest'; import { ToolOutputBuffer, stripAnsi, type OutputTail } from '../src/tool-output-buffer'; import type { ObservedHandle } from '../src/tool-launcher'; /** Build a controllable fake handle. */ function fakeHandle() { let data: ((c: string) => void) | null = null; let exit: ((c: number | null) => void) | null = null; const handle: ObservedHandle = { onData: (cb) => { data = cb; }, onExit: (cb) => { exit = cb; }, }; return { handle, emit: (c: string) => data?.(c), end: (code: number | null) => exit?.(code) }; } describe('stripAnsi', () => { it('removes CSI color sequences', () => { expect(stripAnsi('red')).toBe('red'); }); }); describe('ToolOutputBuffer', () => { it('buffers complete lines and replays them via getTail', () => { const buf = new ToolOutputBuffer(); const f = fakeHandle(); buf.attach(101, f.handle); f.emit('hello\nwor'); f.emit('ld\n'); const tail = buf.getTail(101) as OutputTail; expect(tail.lines).toEqual(['hello', 'world']); expect(tail.exited).toBe(false); }); it('caps the ring buffer at maxLines', () => { const buf = new ToolOutputBuffer({ maxLines: 3 }); const f = fakeHandle(); buf.attach(102, f.handle); f.emit('a\nb\nc\nd\ne\n'); expect(buf.getTail(102)!.lines).toEqual(['c', 'd', 'e']); }); it('flushes the trailing partial line and records exit code on exit', () => { const buf = new ToolOutputBuffer(); const f = fakeHandle(); buf.attach(103, f.handle); f.emit('done-without-newline'); f.end(0); const tail = buf.getTail(103)!; expect(tail.lines).toEqual(['done-without-newline']); expect(tail.exited).toBe(true); expect(tail.exitCode).toBe(0); }); it('subscribe replays existing lines then streams new ones, atomically', () => { const buf = new ToolOutputBuffer(); const f = fakeHandle(); buf.attach(104, f.handle); f.emit('one\n'); const lines: string[] = []; let exitCode: number | null | undefined; buf.subscribe(104, (l) => lines.push(l), (c) => { exitCode = c; }); expect(lines).toEqual(['one']); // replayed synchronously f.emit('two\n'); expect(lines).toEqual(['one', 'two']); // live f.end(3); expect(exitCode).toBe(3); }); it('subscribe on an already-exited pid replays + calls onExit immediately', () => { const buf = new ToolOutputBuffer(); const f = fakeHandle(); buf.attach(105, f.handle); f.emit('x\n'); f.end(0); const lines: string[] = []; let exited = false; buf.subscribe(105, (l) => lines.push(l), () => { exited = true; }); expect(lines).toEqual(['x']); expect(exited).toBe(true); }); it('evicts an exited entry after the grace period (injected scheduler)', () => { let scheduled: (() => void) | null = null; const buf = new ToolOutputBuffer({ scheduleEvict: (cb) => { scheduled = cb; } }); const f = fakeHandle(); buf.attach(106, f.handle); f.end(0); expect(buf.has(106)).toBe(true); scheduled!(); expect(buf.has(106)).toBe(false); }); it('unsubscribe stops further line delivery', () => { const buf = new ToolOutputBuffer(); const f = fakeHandle(); buf.attach(107, f.handle); const lines: string[] = []; const off = buf.subscribe(107, (l) => lines.push(l), () => {}); off(); f.emit('ignored\n'); expect(lines).toEqual([]); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/agent/tests/tool-output-buffer.test.ts` Expected: FAIL — module not found. - [ ] **Step 3: Implement** `packages/agent/src/tool-output-buffer.ts`: ```ts /** * AI-OS #4 — bounded per-pid output buffer for OBSERVED tool launches. * * Owns a ring buffer of stdout/stderr lines for each observed process, capped * by line count and byte size (SignalBus-style bound), ANSI-stripped on ingest. * In-memory only — like the SignalBus and the tracker's non-persisted state, it * is lost on sidecar restart (consistent with observed processes being * tethered). `subscribe` replays the current tail then live-tails atomically so * an SSE client never misses or double-counts a line across the gap. */ import type { ObservedHandle } from './tool-launcher'; const DEFAULT_MAX_LINES = 2000; const DEFAULT_MAX_BYTES = 256 * 1024; const DEFAULT_EVICT_GRACE_MS = 60_000; // CSI / single-char / OSC escape sequences. Inline (no dependency) — strips the // vast majority of terminal control output (colors, cursor moves, titles). // eslint-disable-next-line no-control-regex const ANSI_PATTERN = /(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^]*)/g; export function stripAnsi(s: string): string { return s.replace(ANSI_PATTERN, ''); } export interface OutputTail { lines: string[]; exited: boolean; exitCode: number | null; } export interface ToolOutputBufferDeps { maxLines?: number; maxBytes?: number; evictGraceMs?: number; /** Injectable eviction scheduler (tests capture the callback). */ scheduleEvict?: (cb: () => void, ms: number) => void; } interface Entry { lines: string[]; bytes: number; partial: string; exited: boolean; exitCode: number | null; lineListeners: Set<(line: string) => void>; exitListeners: Set<(code: number | null) => void>; } function defaultScheduleEvict(cb: () => void, ms: number): void { const t = setTimeout(cb, ms); // Don't keep the sidecar event loop alive just to evict a buffer. (t as { unref?: () => void }).unref?.(); } export class ToolOutputBuffer { private entries = new Map(); private readonly maxLines: number; private readonly maxBytes: number; private readonly evictGraceMs: number; private readonly scheduleEvict: (cb: () => void, ms: number) => void; constructor(deps: ToolOutputBufferDeps = {}) { this.maxLines = deps.maxLines ?? DEFAULT_MAX_LINES; this.maxBytes = deps.maxBytes ?? DEFAULT_MAX_BYTES; this.evictGraceMs = deps.evictGraceMs ?? DEFAULT_EVICT_GRACE_MS; this.scheduleEvict = deps.scheduleEvict ?? defaultScheduleEvict; } attach(pid: number, handle: ObservedHandle): void { const entry: Entry = { lines: [], bytes: 0, partial: '', exited: false, exitCode: null, lineListeners: new Set(), exitListeners: new Set(), }; this.entries.set(pid, entry); handle.onData((chunk) => this.ingest(pid, chunk)); handle.onExit((code) => this.finalize(pid, code)); } has(pid: number): boolean { return this.entries.has(pid); } getTail(pid: number): OutputTail | null { const e = this.entries.get(pid); if (!e) return null; return { lines: [...e.lines], exited: e.exited, exitCode: e.exitCode }; } subscribe( pid: number, onLine: (line: string) => void, onExit: (code: number | null) => void, ): () => void { const e = this.entries.get(pid); if (!e) return () => {}; for (const line of e.lines) onLine(line); // atomic replay if (e.exited) { onExit(e.exitCode); return () => {}; } e.lineListeners.add(onLine); e.exitListeners.add(onExit); return () => { e.lineListeners.delete(onLine); e.exitListeners.delete(onExit); }; } private ingest(pid: number, chunk: string): void { const e = this.entries.get(pid); if (!e || e.exited) return; const text = e.partial + stripAnsi(chunk); const parts = text.split('\n'); e.partial = parts.pop() ?? ''; for (const line of parts) this.pushLine(e, line); } private pushLine(e: Entry, line: string): void { e.lines.push(line); e.bytes += Buffer.byteLength(line, 'utf8') + 1; while (e.lines.length > this.maxLines || e.bytes > this.maxBytes) { const removed = e.lines.shift(); if (removed === undefined) break; e.bytes -= Buffer.byteLength(removed, 'utf8') + 1; } for (const l of e.lineListeners) l(line); } private finalize(pid: number, code: number | null): void { const e = this.entries.get(pid); if (!e || e.exited) return; if (e.partial.length > 0) { this.pushLine(e, e.partial); e.partial = ''; } e.exited = true; e.exitCode = code; for (const l of e.exitListeners) l(code); e.lineListeners.clear(); e.exitListeners.clear(); this.scheduleEvict(() => this.entries.delete(pid), this.evictGraceMs); } } ``` - [ ] **Step 4: Export + run** — add to `packages/agent/src/index.ts` (alongside the existing `tool-process-tracker` / `tool-launcher` re-exports): ```ts export { ToolOutputBuffer, stripAnsi, type OutputTail, type ToolOutputBufferDeps, } from './tool-output-buffer'; export type { ObservedHandle } from './tool-launcher'; ``` Run: `npx vitest run packages/agent/tests/tool-output-buffer.test.ts` Expected: PASS (all 8 cases). - [ ] **Step 5: Typecheck + Commit** ```bash npx tsc --noEmit --project packages/agent/tsconfig.json git add packages/agent/src/tool-output-buffer.ts packages/agent/src/index.ts packages/agent/tests/tool-output-buffer.test.ts git commit -m "feat(agent): ToolOutputBuffer bounded per-pid ring buffer" ``` --- ### Task 3: Tracker `observed` flag + persist exclusion **Files:** - Modify: `packages/agent/src/tool-process-tracker.ts` - Test: `packages/agent/tests/tool-process-tracker.test.ts` **Interfaces:** - Produces: `TrackedProcess.observed?: boolean`; `register(pid, toolId, workspaceId?, opts?: { observed?: boolean }): TrackedProcess`. - [ ] **Step 1: Write the failing test** — append to `packages/agent/tests/tool-process-tracker.test.ts`: ```ts describe('observed processes are in-memory only', () => { it('lists observed pids but never persists them', () => { const saved: TrackedProcess[][] = []; const tracker = new ToolProcessTracker({ savePersisted: (recs) => saved.push([...recs]), isAlive: () => true, now: () => new Date('2026-06-30T00:00:00Z'), }); tracker.register(11, 'claude-code', 'ws1'); // detached tracker.register(22, 'cursor', 'ws1', { observed: true }); // observed // Both are live in memory: expect(tracker.list().map((p) => p.pid).sort()).toEqual([11, 22]); // But the most recent persisted snapshot excludes the observed pid: const last = saved[saved.length - 1]; expect(last.map((p) => p.pid)).toEqual([11]); }); }); ``` (`TrackedProcess` is already imported in this test file; if not, add `import type { TrackedProcess } from '../src/tool-process-tracker';`.) - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/agent/tests/tool-process-tracker.test.ts -t "observed"` Expected: FAIL — `register` rejects the 4th arg / observed pid is persisted. - [ ] **Step 3: Implement** in `packages/agent/src/tool-process-tracker.ts`: Add to `interface TrackedProcess`: `observed?: boolean;` In `isTrackedProcess`, allow the field (add before the final `)`): ```ts (r.observed === undefined || typeof r.observed === 'boolean') && ``` Change `register`: ```ts register( pid: number, toolId: ToolId, workspaceId?: string, opts?: { observed?: boolean }, ): TrackedProcess { const record: TrackedProcess = { pid, toolId, startedAt: this.now().toISOString(), ...(workspaceId !== undefined ? { workspaceId } : {}), ...(opts?.observed ? { observed: true } : {}), }; this.processes.set(pid, record); this.persist(); return record; } ``` Change `persist` to exclude observed entries (they cannot survive a restart, so persisting them risks a stale Running badge / pid-reuse false positive): ```ts private persist(): void { if (this.persists) { this.savePersisted(Array.from(this.processes.values()).filter((p) => !p.observed)); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/agent/tests/tool-process-tracker.test.ts` Expected: PASS (new + all existing tracker tests, including persistence/reconcile). - [ ] **Step 5: Typecheck + Commit** ```bash npx tsc --noEmit --project packages/agent/tsconfig.json git add packages/agent/src/tool-process-tracker.ts packages/agent/tests/tool-process-tracker.test.ts git commit -m "feat(agent): tracker observed flag — in-memory only, excluded from pidfile" ``` --- ### Task 4: Server — observe launch, buffer decoration, SSE stream route **Files:** - Modify: `packages/server/src/local/routes/tools.ts` - Modify: `packages/server/src/local/security-middleware.ts:249` (`SSE_QUERY_TOKEN_PATHS`) - Test: `packages/server/tests/tools-routes-launch.test.ts` (extend) or new `packages/server/tests/tools-routes-stream.test.ts` **Interfaces:** - Consumes: `launchTool({observe})`, `ToolOutputBuffer`, `ToolProcessTracker.register(...,{observed})` (Tasks 1–3). - Produces: `POST /api/tools/launch` accepts `observe?`; `GET /api/tools/stream?pid=` (SSE: `event: line` `data:{line}`, `event: exit` `data:{code}`). - [ ] **Step 1: Write the failing test** — `packages/server/tests/tools-routes-stream.test.ts` (use the same Fastify build harness the existing `tools-routes-launch.test.ts` uses — import its `buildTestServer`/`registerToolsRoutes` helper; mirror its setup): ```ts import { describe, it, expect } from 'vitest'; // NOTE during execution: import the SAME app/builder helper that // tools-routes-launch.test.ts uses; inject ToolLauncher deps so no real // process spawns. Pattern below assumes a `buildToolsApp({ launcherDeps })`. describe('GET /api/tools/stream', () => { it('404s when the pid has no observed output buffer', async () => { const app = await buildToolsApp(); const res = await app.inject({ method: 'GET', url: '/api/tools/stream?pid=999999' }); expect(res.statusCode).toBe(404); await app.close(); }); it('400s on a missing/invalid pid', async () => { const app = await buildToolsApp(); const res = await app.inject({ method: 'GET', url: '/api/tools/stream?pid=abc' }); expect(res.statusCode).toBe(400); await app.close(); }); it('launch with observe:true registers + attaches a buffer the stream can read', async () => { // Inject a fake spawnObserved that emits one line then exits, so the // buffer is populated synchronously; then assert the SSE body replays it. const { app, emit, end } = await buildToolsAppWithObservable(); // see execution note const launch = await app.inject({ method: 'POST', url: '/api/tools/launch', payload: { id: 'claude-code', installedPath: '/usr/bin/claude', observe: true }, }); expect(launch.statusCode).toBe(202); const pid = launch.json().pid as number; emit('hello\n'); end(0); const stream = await app.inject({ method: 'GET', url: `/api/tools/stream?pid=${pid}` }); expect(stream.statusCode).toBe(200); expect(stream.payload).toContain('event: line'); expect(stream.payload).toContain('hello'); expect(stream.payload).toContain('event: exit'); await app.close(); }); }); ``` > **Execution note (no placeholder):** `tools-routes-launch.test.ts` already constructs the Fastify app with injectable launcher deps. Reuse that exact builder. For `buildToolsAppWithObservable`, inject `spawnObserved: () => ({ pid: 4242, handle })` where `handle` is the controllable `fakeHandle()` from Task 2's test, and have the route construct its `ToolOutputBuffer` from a deps factory so the test shares the same instance. If the route owns a private buffer, expose it for tests the same way the tracker is exposed via `server.toolProcessTracker` (decorate `server.toolOutputBuffer`), and read it through `app.toolOutputBuffer`. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/server/tests/tools-routes-stream.test.ts` Expected: FAIL — route + schema field absent. - [ ] **Step 3: Implement** in `packages/server/src/local/routes/tools.ts`: (a) Imports — add `ToolOutputBuffer`: ```ts import { detectInstalledTools, launchTool, runHookCommand, ToolProcessTracker, ToolOutputBuffer, type HookAction, } from '@waggle/agent'; ``` (b) Decoration — extend the module augmentation and lazy-init beside the tracker: ```ts declare module 'fastify' { interface FastifyInstance { toolProcessTracker?: ToolProcessTracker; /** AI-OS #4 — bounded per-pid output buffer for observed launches. */ toolOutputBuffer?: ToolOutputBuffer; } } ``` ```ts if (!server.toolOutputBuffer) { server.decorate('toolOutputBuffer', new ToolOutputBuffer()); } const outputBuffer = server.toolOutputBuffer!; ``` (c) Schema — add `observe` to `launchBodySchema`: ```ts observe: z.boolean().optional(), ``` (d) Launch handler — thread observe through and attach the buffer: ```ts const result = launchTool({ id: body.id, installedPath: body.installedPath, workspaceId: body.workspaceId, cwd: body.cwd, args: body.args, signalEmit: true, sidecarUrl: loopbackSidecarUrl(request.headers.host), observe: body.observe, }); if (!result.ok) { return reply.code(400).send(result); } if (result.pid != null) { tracker.register(result.pid, body.id, body.workspaceId, { observed: body.observe === true }); if (body.observe && result.output) { outputBuffer.attach(result.pid, result.output); } } return reply.code(202).send(result); ``` (e) New SSE route — add after `/api/tools/kill`: ```ts // ── GET /api/tools/stream?pid= (AI-OS #4) ──────────────────────── // Live stdout/stderr for an OBSERVED launch. Validates against the // output buffer (the only place observed pids land), then mirrors the // chat.ts hijack-SSE pattern: replay the tail, then live-tail, then a // terminal `exit` event. Loopback-only; auth via ?token= (SSE allowlist). server.get('/api/tools/stream', async (request, reply) => { const raw = (request.query as { pid?: string } | undefined)?.pid; const pid = Number(raw); if (!Number.isInteger(pid) || pid <= 0) { return reply.code(400).send({ error: 'pid query param required' }); } if (!outputBuffer.has(pid)) { return reply.code(404).send({ error: 'no observed output for pid' }); } await reply.hijack(); const res = reply.raw; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': request.headers.origin ?? '*', }); const send = (event: string, data: unknown) => { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); }; const unsubscribe = outputBuffer.subscribe( pid, (line) => send('line', { line }), (code) => { send('exit', { code }); res.end(); }, ); res.on('close', () => unsubscribe()); }); ``` (f) `packages/server/src/local/security-middleware.ts` — add to `SSE_QUERY_TOKEN_PATHS` (line ~249): ```ts '/api/tools/stream', // AI-OS #4 — observed-launch live output (named line/exit events) ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/server/tests/tools-routes-stream.test.ts packages/server/tests/tools-routes-launch.test.ts` Expected: PASS (new stream tests + existing launch tests unaffected). - [ ] **Step 5: Typecheck + Commit** ```bash npx tsc --noEmit --project packages/server/tsconfig.json git add packages/server/src/local/routes/tools.ts packages/server/src/local/security-middleware.ts packages/server/tests/tools-routes-stream.test.ts git commit -m "feat(server): /api/tools/stream SSE + observe launch wiring" ``` --- ### Task 5: Adapter — `observe` param + `streamToolOutput` **Files:** - Modify: `apps/web/src/lib/adapter.ts` - Test: `apps/web/src/lib/adapter.launcher.test.ts` (new) — assert the request body carries `observe` (mirror the mocking style of `adapter.authgate.test.ts`). **Interfaces:** - Produces: `launchTool({ ..., observe?: boolean })`; `streamToolOutput(pid:number, handlers:{ onLine:(line:string)=>void; onExit:(code:number|null)=>void }): () => void`. - [ ] **Step 1: Write the failing test** — `apps/web/src/lib/adapter.launcher.test.ts`: ```ts import { describe, it, expect, vi } from 'vitest'; import { adapter } from './adapter'; describe('adapter.launchTool observe', () => { it('forwards observe:true in the POST body', async () => { const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ ok: true, pid: 5 }), { status: 202 }), ); await adapter.launchTool({ id: 'claude-code', installedPath: '/x', observe: true }); const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string); expect(body.observe).toBe(true); spy.mockRestore(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run apps/web/src/lib/adapter.launcher.test.ts` Expected: FAIL — `observe` not in the `launchTool` payload type. - [ ] **Step 3: Implement** in `apps/web/src/lib/adapter.ts`: Add `observe?: boolean;` to the `launchTool` `payload` parameter type (the existing object after `cwd?`). The body is already `JSON.stringify(payload)`, so the field flows through automatically. Add the streamer method right after `getToolProcesses` (it reuses the private `openSSE` EventSource lifecycle — token-attached, reconnecting): ```ts /** * AI-OS #4 — subscribe to an observed launch's live output. Opens a * dedicated EventSource on /api/tools/stream?pid= (two named events: * `line` and `exit`). Returns an unsubscribe handle. No-ops in jsdom * (no EventSource) — unit tests drive the pane via the callbacks directly. */ streamToolOutput( pid: number, handlers: { onLine: (line: string) => void; onExit: (code: number | null) => void }, ): () => void { return this.openSSE(`/api/tools/stream?pid=${pid}`, (es) => { es.addEventListener('line', (e) => { try { handlers.onLine(JSON.parse((e as MessageEvent).data).line); } catch { /* skip */ } }); es.addEventListener('exit', (e) => { try { handlers.onExit(JSON.parse((e as MessageEvent).data).code ?? null); } catch { /* skip */ } }); }); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run apps/web/src/lib/adapter.launcher.test.ts` Expected: PASS. - [ ] **Step 5: Typecheck + Commit** ```bash npx tsc --noEmit -p apps/web/tsconfig.json git add apps/web/src/lib/adapter.ts apps/web/src/lib/adapter.launcher.test.ts git commit -m "feat(web): adapter observe param + streamToolOutput SSE" ``` --- ### Task 6: `ToolOutputPane` component **Files:** - Create: `apps/web/src/components/os/apps/launcher/ToolOutputPane.tsx` - Test: `apps/web/src/components/os/apps/launcher/ToolOutputPane.test.tsx` **Interfaces:** - Consumes: `adapter.streamToolOutput` (Task 5). - Produces: `ToolOutputPane({ pid, toolId, observed })` — `observed=false` ⇒ render the detached-launch hint instead of opening a stream. - [ ] **Step 1: Write the failing test**: ```tsx import { describe, it, expect, vi } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import { ToolOutputPane } from './ToolOutputPane'; import { adapter } from '@/lib/adapter'; describe('ToolOutputPane', () => { it('renders streamed lines and the exit chip', () => { let onLine!: (l: string) => void; let onExit!: (c: number | null) => void; vi.spyOn(adapter, 'streamToolOutput').mockImplementation((_pid, h) => { onLine = h.onLine; onExit = h.onExit; return () => {}; }); render(); act(() => { onLine('building…'); onLine('done'); onExit(0); }); expect(screen.getByText('building…')).toBeInTheDocument(); expect(screen.getByText('done')).toBeInTheDocument(); expect(screen.getByText(/exit 0/i)).toBeInTheDocument(); }); it('shows the detached-launch hint when not observed (no stream opened)', () => { const spy = vi.spyOn(adapter, 'streamToolOutput'); render(); expect(spy).not.toHaveBeenCalled(); expect(screen.getByText(/Watch a coding agent live/i)).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run apps/web/src/components/os/apps/launcher/ToolOutputPane.test.tsx` Expected: FAIL — module not found. - [ ] **Step 3: Implement** `ToolOutputPane.tsx`: ```tsx import { useEffect, useRef, useState } from 'react'; import { Loader2, Terminal } from 'lucide-react'; import { ScrollArea } from '@/components/ui/scroll-area'; import { adapter } from '@/lib/adapter'; interface ToolOutputPaneProps { pid: number; toolId: string; /** True only for launches started in observed mode (piped stdio). */ observed: boolean; } const RENDER_CAP = 2000; // mirror the server ring-buffer cap export const ToolOutputPane = ({ pid, toolId, observed }: ToolOutputPaneProps) => { const [lines, setLines] = useState([]); const [exitCode, setExitCode] = useState(undefined); const scrollRef = useRef(null); useEffect(() => { if (!observed) return; const off = adapter.streamToolOutput(pid, { onLine: (line) => setLines((prev) => { const next = prev.length >= RENDER_CAP ? prev.slice(prev.length - RENDER_CAP + 1) : prev; return [...next, line]; }), onExit: (code) => setExitCode(code), }); return off; }, [pid, observed]); // Auto-scroll to bottom on new output. useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [lines]); if (!observed) { return (
This agent was launched in the background (no live output). Use{' '} ⌘K → “Watch a coding agent live” to start one you can watch.
); } return (
{toolId} · live output {exitCode === undefined ? ( ) : ( exit {exitCode ?? 'signal'} )}
{lines.length === 0 && exitCode === undefined ? ( Waiting for output… ) : ( lines.map((l, i) =>
{l || ' '}
) )}
); }; export default ToolOutputPane; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run apps/web/src/components/os/apps/launcher/ToolOutputPane.test.tsx` Expected: PASS. - [ ] **Step 5: Typecheck + Commit** ```bash npx tsc --noEmit -p apps/web/tsconfig.json git add apps/web/src/components/os/apps/launcher/ToolOutputPane.tsx apps/web/src/components/os/apps/launcher/ToolOutputPane.test.tsx git commit -m "feat(web): ToolOutputPane live-output component" ``` --- ### Task 7: `LauncherApp` — clickable Running badge + pane + watch mode **Files:** - Modify: `apps/web/src/components/os/apps/LauncherApp.tsx` - Test: `apps/web/src/components/os/apps/LauncherApp.test.tsx` (extend) **Interfaces:** - Consumes: `ToolOutputPane` (Task 6), `adapter.launchTool({observe})` (Task 5), `?watch=1` route param. - Produces: badge-click reveals ``; in watch mode the per-tool Launch sends `observe:true`. - [ ] **Step 1: Write the failing test** — extend `LauncherApp.test.tsx` with one case (mirror the file's existing adapter-mocking setup; key the running tool so the badge shows): ```tsx it('reveals the output pane when a running tool badge is clicked', async () => { // Arrange: detect returns one installed tool; processes poll returns it running observed. // (Reuse the file's existing mock helpers for detectTools/getToolProcesses.) render(); const badge = await screen.findByText('Running'); await userEvent.click(badge); expect(await screen.findByText(/live output|Waiting for output/i)).toBeInTheDocument(); }); ``` > **Execution note:** read the existing `LauncherApp.test.tsx` mock setup and reuse its `adapter` mocks; add `observed: true` to the mocked `getToolProcesses` record so the pane opens a stream (mock `adapter.streamToolOutput` to a no-op returning `() => {}`). - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run apps/web/src/components/os/apps/LauncherApp.test.tsx -t "output pane"` Expected: FAIL — badge is not interactive; pane absent. - [ ] **Step 3: Implement** in `LauncherApp.tsx`: (a) Imports: `import { ToolOutputPane } from './launcher/ToolOutputPane';` and `useState` is already imported. (b) Track which pids are observed (extend the existing process poll). Add state: ```ts const [observedPids, setObservedPids] = useState>(new Set()); const [openPaneToolId, setOpenPaneToolId] = useState(null); ``` In the `pollOnce` of the processes `useEffect` (and in `stopTool`'s re-poll), populate observed pids from the record's `observed` flag: ```ts setObservedPids(new Set(result.processes.filter((p) => p.observed).map((p) => p.pid))); ``` (Extend the `getToolProcesses` return type in `adapter.ts` Task 5 with `observed?: boolean` on the process entries so this typechecks — add it there.) (c) `?watch=1` mode — read the param once (the launcher host route is `/launcher`; use the same router hook the app uses, e.g. `useSearchParams` from react-router if present, else parse `window.location.search`). Minimal, framework-agnostic: ```ts const watchMode = useMemo( () => new URLSearchParams(window.location.search).get('watch') === '1', [], ); ``` In `doAction`'s `launch` branch, pass observe in watch mode and auto-open the pane: ```ts const r = await adapter.launchTool({ id: tool.id, installedPath: tool.installedPath, workspaceId: activeWorkspaceId, ...(args ? { args } : {}), ...(watchMode ? { observe: true } : {}), }); ``` After a successful watch-mode launch, `if (r.ok && watchMode) setOpenPaneToolId(tool.id);`. (d) Make the Running badge a button that toggles the pane: ```tsx {runningTools.has(tool.id) && ( )} ``` (e) Render the pane under the actions row when open, choosing the first running pid for the tool: ```tsx {openPaneToolId === tool.id && (() => { const pid = (pidsByTool.get(tool.id) ?? [])[0]; if (pid == null) return null; return ; })()} ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run apps/web/src/components/os/apps/LauncherApp.test.tsx` Expected: PASS (new + existing 5 LauncherApp tests). - [ ] **Step 5: Typecheck + Commit** ```bash npx tsc --noEmit -p apps/web/tsconfig.json git add apps/web/src/components/os/apps/LauncherApp.tsx apps/web/src/components/os/apps/LauncherApp.test.tsx apps/web/src/lib/adapter.ts git commit -m "feat(web): launcher Running-badge reveals live output + watch mode" ``` --- ### Task 8: ⌘K "Watch a coding agent live" catalog entry **Files:** - Modify: `apps/web/src/lib/command-catalog.ts` - Test: `apps/web/src/lib/command-catalog.test.ts` (new or extend) **Interfaces:** - Produces: a curated catalog item `{ id:'watch-agent', name:'Watch a coding agent live', to:'/launcher?watch=1' }` in the `do` group. - [ ] **Step 1: Write the failing test** — `apps/web/src/lib/command-catalog.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { buildCommandCatalog } from './command-catalog'; // use the file's actual exported builder name describe('command catalog watch-agent', () => { it('includes a "Watch a coding agent live" entry deep-linking to watch mode', () => { const groups = buildCommandCatalog(/* pass the same args the app passes; see execution note */); const all = groups.flatMap((g) => g.items); const watch = all.find((i) => i.id === 'watch-agent'); expect(watch).toBeTruthy(); expect(watch!.to).toBe('/launcher?watch=1'); }); }); ``` > **Execution note:** open `command-catalog.ts`, confirm the exported builder's real name and signature (the spec references the `launch-agent` item near line 76), and call it with the minimal valid args. If the catalog is a plain exported array rather than a builder fn, assert against that array instead. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run apps/web/src/lib/command-catalog.test.ts` Expected: FAIL — no `watch-agent` item. - [ ] **Step 3: Implement** — in `command-catalog.ts`, add `Eye` to the lucide import and insert next to the `launch-agent` item: ```ts { id: "watch-agent", group: "do", name: "Watch a coding agent live", subtitle: "Claude Code · Cursor · Codex — stream its output", icon: Eye, to: "/launcher?watch=1" }, ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run apps/web/src/lib/command-catalog.test.ts` Expected: PASS. - [ ] **Step 5: Typecheck + Commit** ```bash npx tsc --noEmit -p apps/web/tsconfig.json git add apps/web/src/lib/command-catalog.ts apps/web/src/lib/command-catalog.test.ts git commit -m "feat(web): ⌘K 'Watch a coding agent live' catalog entry" ``` --- ### Final Gate (after Task 8) - [ ] **Full typecheck** — `npx tsc --noEmit --project packages/agent/tsconfig.json && npx tsc --noEmit --project packages/server/tsconfig.json && npx tsc --noEmit -p apps/web/tsconfig.json` → 0 errors. - [ ] **Touched suites green** — `npx vitest run packages/agent/tests/tool-launcher.test.ts packages/agent/tests/tool-output-buffer.test.ts packages/agent/tests/tool-process-tracker.test.ts packages/server/tests/tools-routes-stream.test.ts packages/server/tests/tools-routes-launch.test.ts apps/web/src/lib/adapter.launcher.test.ts apps/web/src/components/os/apps/launcher/ToolOutputPane.test.tsx apps/web/src/components/os/apps/LauncherApp.test.tsx apps/web/src/lib/command-catalog.test.ts` - [ ] **Lint touched files** — `npm run lint` (or scoped) clean. - [ ] Update CLAUDE.md §10 sprint status with the #4 ship line (optional, do at handoff). --- ## Self-Review **Spec coverage:** §3.1 spawnObserved → Task 1 · §3.2 buffer → Task 2 · §3.3 tracker → Task 3 · §3.4 routes + SSE allowlist → Task 4 · §4.1 adapter → Task 5 · §4.4 pane → Task 6 · §4.2 LauncherApp badge/watch → Task 7 · §4.3 ⌘K → Task 8 · §7 testing → each task's TDD steps + Final Gate. No spec section is unmapped. **Placeholder scan:** the three "execution notes" point at *existing* code to mirror (test builders / exported names) rather than leaving logic undefined — every code step ships real code. No TBD/TODO. **Type consistency:** `ObservedHandle`/`spawnObserved`/`output` (Task 1) match their use in Tasks 2/4. `register(...,{observed})` (Task 3) matches the Task 4 call. `streamToolOutput(pid,{onLine,onExit})` (Task 5) matches Task 6's mock and the SSE `line`/`exit` events from Task 4. `getToolProcesses` gains `observed?` in Task 5, consumed in Task 7. `OutputTail`/`has`/`subscribe`/`getTail` names are consistent across Tasks 2 and 4.