Files
waggle-os/docs/superpowers/plans/2026-06-30-launcher-live-output.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

1187 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<ToolLauncherDeps['spawnObserved']>;` 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<number, Entry>();
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 13).
- 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(<ToolOutputPane pid={5} toolId="claude-code" observed />);
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(<ToolOutputPane pid={6} toolId="cursor" observed={false} />);
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<string[]>([]);
const [exitCode, setExitCode] = useState<number | null | undefined>(undefined);
const scrollRef = useRef<HTMLDivElement>(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 (
<div className="mt-2 rounded-lg border border-border/40 bg-card/30 p-3 text-[11px] text-muted-foreground">
This agent was launched in the background (no live output). Use{' '}
<span className="font-medium text-foreground">K Watch a coding agent live</span> to
start one you can watch.
</div>
);
}
return (
<div className="mt-2 rounded-lg border border-border/40 bg-[var(--bg-2,#0a0b0e)] overflow-hidden">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b border-border/30 text-[11px] text-muted-foreground">
<Terminal className="w-3 h-3" />
<span>{toolId} · live output</span>
{exitCode === undefined ? (
<Loader2 className="w-3 h-3 animate-spin ml-auto" />
) : (
<span
className="ml-auto rounded px-1.5 py-0.5 text-[10px]"
style={
exitCode === 0
? { background: 'var(--healthy-wash)', color: 'var(--healthy)' }
: { background: 'var(--risk-wash)', color: 'var(--risk)' }
}
>
exit {exitCode ?? 'signal'}
</span>
)}
</div>
<ScrollArea className="max-h-56">
<div ref={scrollRef} className="p-2.5 font-mono text-[11px] leading-[1.5] whitespace-pre-wrap break-all text-[var(--text-2)]">
{lines.length === 0 && exitCode === undefined ? (
<span className="text-muted-foreground">Waiting for output</span>
) : (
lines.map((l, i) => <div key={i}>{l || ' '}</div>)
)}
</div>
</ScrollArea>
</div>
);
};
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 `<ToolOutputPane>`; 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(<LauncherApp activeWorkspaceId="ws1" />);
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<Set<number>>(new Set());
const [openPaneToolId, setOpenPaneToolId] = useState<string | null>(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) && (
<button
type="button"
onClick={() => setOpenPaneToolId((cur) => (cur === tool.id ? null : tool.id))}
aria-expanded={openPaneToolId === tool.id}
className="text-[10px] px-1.5 py-0 h-4 inline-flex items-center rounded"
style={{ background: 'var(--work-wash)', color: 'var(--work)' }}
title="Show live output"
>
<span className="w-1.5 h-1.5 rounded-full inline-block mr-1 animate-pulse" style={{ background: 'var(--work)' }} />
Running
</button>
)}
```
(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 <ToolOutputPane pid={pid} toolId={tool.id} observed={observedPids.has(pid)} />;
})()}
```
- [ ] **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.