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

13 KiB
Raw Blame History

Launcher Live-Output Pane (piped, observed mode) — Design

Date: 2026-06-30 · Author: Claude Opus 4.8 (1M) · Owner: Marko (founder) Arc: AI-OS external-agent launcher · STEAL NOW item #4 from docs/analysis/external-agent-launching-and-memory-comparison-2026-06-29.md Branch: feat/launcher-self-enabling-pipeline (continues the launcher trio shipped in 4ade22f7) Capture model (founder-approved 2026-06-30): piped stdio, not node-pty.


1. Goal & Non-Goals

Goal. Close the launcher's "no eyes" gap. Today launchTool() spawns external agents detached + stdio:'ignore' + unref(), so Waggle sees nothing until a Stop-hook frame lands. Give the user an opt-in way to watch a launched agent's live stdout/stderr in the dock.

Non-goals (YAGNI — explicitly out of scope for v1):

  • No node-pty / true terminal emulation, no ANSI/TUI rendering fidelity, no xterm.js.
  • No input send back to the agent (that is the node-pty path; deferred).
  • No multi-process tabbed terminal — one pane per running tool, opened on demand.
  • No auto-ingest of captured output into memory (display-only → no injection surface).
  • No change to the default detached launch path or to the #2 persistence guarantee.

2. The Crux: Two Launch Modes

The feature rides on one inversion of the existing contract:

Detached (default, unchanged) Observed (new, opt-in)
spawn detached:true, stdio:'ignore', unref() stdio:['ignore','pipe','pipe'], no unref()
survives sidecar restart Yes (the whole point; #2 reconciles it) No — sidecar holds the pipes; tethered
live output none streamed to the dock
tracker persistence persisted to pidfile in-memory only (can't survive → must not claim to)

Making observation a mode rather than a replacement is what lets #4 coexist with the 4ade22f7 persistence (#2) instead of silently breaking its "survives restart" promise.


3. Backend Design

3.1 spawnObserved DI seam — packages/agent/src/tool-launcher.ts

New injectable dep mirroring spawnDetached, kept hermetic so tests never spawn real processes. It returns the pid plus an abstract output handle so the buffer can subscribe without leaking ChildProcess into the pure surface:

/** 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;
}

spawnObserved?: (
  binary: string,
  args: string[],
  options: { cwd?: string; env?: NodeJS.ProcessEnv },
) => { pid: number | null; error?: string; handle?: ObservedHandle };

Production default: spawn(binary, args, { cwd, env, stdio: ['ignore','pipe','pipe'] }) (no detached, no unref); wires child.stdout/child.stderr 'data'onData (utf8), and child 'exit'onExit.

launchTool() gains observe?: boolean. When observe === true it calls spawnObserved (falling back to the same LAUNCH_COHORT/installedPath guards) and returns the existing LaunchResult plus an optional output?: ObservedHandle. When false/absent the path is byte-for-byte today's spawnDetached. The signal-emit / sidecar-url env injection is shared across both modes (already in place).

3.2 Output buffer — packages/agent/src/tool-output-buffer.ts (new)

A ToolOutputBuffer class owning a bounded ring buffer per pid, modeled on the SignalBus 500-cap philosophy:

  • attach(pid, handle: ObservedHandle) — subscribe; push chunks (split to lines, ANSI stripped on ingest via an inline regex — no new dependency) into a ring capped at MAX_LINES = 2000 and MAX_BYTES = 256 KB (whichever first; oldest evicted).
  • getTail(pid): { lines: string[]; exited: boolean; exitCode: number | null } — replay.
  • subscribe(pid, listener): () => void — live fan-out to SSE clients; returns an unsubscribe.
  • On onExit: stamp exited/exitCode, emit a terminal event to live listeners, retain the tail for late readers, and schedule eviction of the whole entry after a short grace (so a pane opened just after exit still shows the final output).

In-memory only — like the tracker's non-persisted state and the SignalBus, it is lost on sidecar restart (consistent with observed processes being tethered).

3.3 Tracker change — packages/agent/src/tool-process-tracker.ts

  • TrackedProcess gains optional observed?: boolean.
  • register(pid, toolId, workspaceId?, opts?: { observed?: boolean }).
  • persist() writes records.filter(p => !p.observed)observed pids are never persisted, so a sidecar restart can never resurrect a stale "Running" badge for a tethered (now-dead) process or a pid-reused stranger. Observed pids still appear in list() while the sidecar lives, so the badge + pane work for the whole session.

3.4 Routes — packages/server/src/local/routes/tools.ts

  • POST /api/tools/launchlaunchBodySchema gains observe: z.boolean().optional(). When observe, the handler: calls launchTool({ ..., observe: true }); on success tracker.register(pid, id, workspaceId, { observed: true }) and outputBuffer.attach(pid, result.output). Response unchanged (202 + { ok, pid }).
  • GET /api/tools/stream?pid= (new, SSE) — validates pid is tracked AND observed (404 otherwise); then mirrors the proven chat.ts:580 pattern: reply.hijack()raw.writeHead(200, text/event-stream + loopback CORS) → replay getTail as event: line frames → subscribe for live line frames → on exit emit event: exit data: { code } and raw.end(). reply.raw.on('close', unsubscribe) cleans up on client disconnect. No new buffer/transport primitive — pure composition.
  • outputBuffer is decorated on the Fastify instance exactly like toolProcessTracker (lazy-init, fastify-plugin-propagated) so /launch and /stream share one instance.

3.5 Security & resource bounds

  • Output is display-only, never auto-ingested → no LLM/injection surface; React escapes all text; ANSI is stripped so no terminal control sequences reach the DOM.
  • /stream is loopback-bound like every local route; pid must be one we spawned and marked observed (reuses the tracker's "only our pids" guard philosophy).
  • Ring-buffer caps (2000 lines / 256 KB / pid) bound memory; entries evicted after exit grace.

4. Frontend Design

Constraint (founder, 2026-06-30): keep current UX, progressive disclosure — only basics on the dock menu, richer entry points on ⌘K.

4.1 apps/web/src/lib/adapter.ts

  • launchTool(payload) gains optional observe?: boolean in its body.
  • New streamToolOutput(pid, { onLine, onExit, signal }) — opens GET /api/tools/stream?pid= via the same auth'd fetch + ReadableStream reader + event:/data: frame parse the chat SSE path already uses (no EventSource, which can't carry the device token). Returns a close handle; aborts via AbortSignal.

4.2 LauncherApp.tsx — dock unchanged, badge becomes the disclosure

  • The per-tool cards and their basic buttons (Launch/Stop/Install/Verify/Uninstall) are untouched. The default dock Launch stays detached (no behavior change).
  • Progressive disclosure: the existing Running badge becomes clickable. Clicking it toggles an inline collapsible <ToolOutputPane pid=… toolId=… /> beneath that card.
    • Observed launch → pane streams live output (mono scroll area, auto-scroll-to-bottom unless the user scrolled up, exit-code footer).
    • Detached launch (no buffer) → pane shows a one-line hint: "This agent was launched in the background (no live output). Use ⌘K → Watch a coding agent live to start one you can watch." — honest, no fake stream.
  • Watch mode (entered via ⌘K deep-link, below): a ?watch=1 route param puts LauncherApp in a mode where the per-tool Launch action sends observe:true and auto-opens that tool's pane. No new always-visible buttons — it reuses the existing Launch control's intent.

4.3 ⌘K — lib/command-catalog.ts (the rich entry point)

Add one curated catalog item next to the existing launch-agent entry:

{ 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" }

This honors "basics on the dock, depth one keystroke away": the watch path is discoverable in ⌘K and deep-links into LauncherApp's watch mode; the dock itself gains no new buttons. Exact param plumbing (useSearchParams in the launcher host) is a plan detail.

4.4 New component — apps/web/src/components/os/apps/launcher/ToolOutputPane.tsx

Self-contained: takes { pid, toolId }, opens adapter.streamToolOutput on mount, renders a bounded virtualized-enough mono list (cap render to last N lines to match the server cap), shows a spinner until first line, an exit-code chip on close, and a copy-all affordance. Cleans up the stream on unmount. ~one focused file (<200 LOC), one clear purpose.


5. Data Flow

⌘K "Watch a coding agent live"  ──▶ /launcher?watch=1
  └▶ LauncherApp (watch mode): Launch ──▶ adapter.launchTool({ id, …, observe:true })
       └▶ POST /api/tools/launch {observe} ─▶ launchTool({observe:true})
            └▶ spawnObserved → {pid, handle}
                 ├▶ tracker.register(pid, id, ws, {observed:true})   (in-memory, not persisted)
                 └▶ outputBuffer.attach(pid, handle)  ── ring buffer (2000 ln / 256 KB, ANSI-stripped)
  Running badge click ──▶ <ToolOutputPane pid>
       └▶ adapter.streamToolOutput(pid) ─▶ GET /api/tools/stream?pid  (SSE, reply.hijack)
            └▶ replay tail → live `line` frames → `exit` frame → close

6. Error Handling

  • spawnObserved failure → launchTool returns ok:false with the spawn error (today's path).
  • /stream with an unknown/non-observed/dead pid → 404 { error }; pane shows the hint, not a spinner-forever.
  • Client disconnect / unmount → reply.raw.on('close') unsubscribes; AbortSignal tears down the fetch reader.
  • Process exits → terminal exit event with code; pane freezes the final tail + shows the chip.
  • Buffer overflow → oldest lines evicted silently (bounded by design); pane mirrors the server cap.
  • Sidecar restart mid-watch → stream errors out; pane shows "output ended (sidecar restarted)"; the observed pid is gone from list() so the badge clears on next 5s poll.

7. Testing (TDD — mirror 4ade22f7's gate discipline)

Backend (packages/agent, packages/server):

  • spawnObserved injected fake emits synthetic data/exit → buffer fills, caps at 2000 lines / 256 KB, finalizes with exit code, evicts after grace.
  • launchTool({observe:true}) returns output handle; observe:false/absent unchanged (regression-lock).
  • tracker.register(..., {observed:true}) lists the pid but persist() excludes it; reconcile never sees it.
  • /api/tools/launch {observe} registers + attaches; /api/tools/stream replays tail, streams live frames, emits exit, 404s unknown/non-observed pid, unsubscribes on close.

Frontend (apps/web):

  • Running badge toggles the pane; pane renders streamed lines (mocked streamToolOutput), shows exit chip, shows the detached-launch hint when no buffer.
  • ANSI-bearing lines render stripped; ⌘K catalog exposes "Watch a coding agent live".

Gates: tsc --noEmit 0 across agent/server/web · all new units RED→GREEN · existing launcher suites stay green (tool-launcher / tool-process-tracker / tools-routes / LauncherApp).


8. File-by-File Change List

File Change
packages/agent/src/tool-launcher.ts ObservedHandle type, spawnObserved dep + default, launchTool observe option + output in result
packages/agent/src/tool-output-buffer.ts newToolOutputBuffer ring buffer (attach/getTail/subscribe/exit + ANSI strip)
packages/agent/src/tool-process-tracker.ts observed? on TrackedProcess; register opt; persist() filters observed
packages/agent/src/index.ts export ToolOutputBuffer, ObservedHandle
packages/server/src/local/routes/tools.ts observe in launch schema/handler; decorate toolOutputBuffer; new GET /api/tools/stream (SSE)
apps/web/src/lib/adapter.ts observe in launchTool; new streamToolOutput
apps/web/src/components/os/apps/LauncherApp.tsx clickable Running badge → pane; ?watch=1 mode
apps/web/src/components/os/apps/launcher/ToolOutputPane.tsx new — live output pane
apps/web/src/lib/command-catalog.ts watch-agent ⌘K entry
tests (46 files) per §7

9. Open Questions / Decisions

  • None blocking. All three forks resolved: piped (not pty), observed-mode (not replacement), ⌘K-deep-link (not new dock buttons).
  • Deferred to a later arc (noted, not built): node-pty upgrade for true terminal + input send; auto-tail of detached launches via a log file; multi-process terminal tabs.
  • Founder decisions from the analysis doc unaffected by this item (budget caps #13, recall-gate cost #8, tuiui re-recon) — out of scope here.