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

45 KiB
Raw Blame History

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 onlyToolProcessTracker.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:

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:

/** 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:

  /**
   * 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:

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):

  /**
   * 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:

  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
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 testpackages/agent/tests/tool-output-buffer.test.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:
/**
 * 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):
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
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:

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 )):

    (r.observed === undefined || typeof r.observed === 'boolean') &&

Change register:

  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):

  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
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 testpackages/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):

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:

import {
  detectInstalledTools,
  launchTool,
  runHookCommand,
  ToolProcessTracker,
  ToolOutputBuffer,
  type HookAction,
} from '@waggle/agent';

(b) Decoration — extend the module augmentation and lazy-init beside the tracker:

declare module 'fastify' {
  interface FastifyInstance {
    toolProcessTracker?: ToolProcessTracker;
    /** AI-OS #4 — bounded per-pid output buffer for observed launches. */
    toolOutputBuffer?: ToolOutputBuffer;
  }
}
  if (!server.toolOutputBuffer) {
    server.decorate('toolOutputBuffer', new ToolOutputBuffer());
  }
  const outputBuffer = server.toolOutputBuffer!;

(c) Schema — add observe to launchBodySchema:

  observe: z.boolean().optional(),

(d) Launch handler — thread observe through and attach the buffer:

    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:

  // ── 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):

  '/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
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 testapps/web/src/lib/adapter.launcher.test.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):

  /**
   * 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
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:

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:
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
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):

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:

  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:

      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:

  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:

          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:

                      {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:

                {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
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 testapps/web/src/lib/command-catalog.test.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:
    { 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
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 typechecknpx 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 greennpx 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 filesnpm 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.