13 KiB
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, noxterm.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 atMAX_LINES = 2000andMAX_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: stampexited/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
TrackedProcessgains optionalobserved?: boolean.register(pid, toolId, workspaceId?, opts?: { observed?: boolean }).persist()writesrecords.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 inlist()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/launch—launchBodySchemagainsobserve: z.boolean().optional(). Whenobserve, the handler: callslaunchTool({ ..., observe: true }); on successtracker.register(pid, id, workspaceId, { observed: true })andoutputBuffer.attach(pid, result.output). Response unchanged (202 +{ ok, pid }).GET /api/tools/stream?pid=(new, SSE) — validatespidis tracked AND observed (404 otherwise); then mirrors the provenchat.ts:580pattern:reply.hijack()→raw.writeHead(200, text/event-stream + loopback CORS)→ replaygetTailasevent: lineframes →subscribefor livelineframes → on exit emitevent: exitdata: { code }andraw.end().reply.raw.on('close', unsubscribe)cleans up on client disconnect. No new buffer/transport primitive — pure composition.outputBufferis decorated on the Fastify instance exactly liketoolProcessTracker(lazy-init,fastify-plugin-propagated) so/launchand/streamshare 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.
/streamis loopback-bound like every local route;pidmust 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 optionalobserve?: booleanin its body.- New
streamToolOutput(pid, { onLine, onExit, signal })— opensGET /api/tools/stream?pid=via the same auth'dfetch+ReadableStreamreader +event:/data:frame parse the chat SSE path already uses (noEventSource, which can't carry the device token). Returns a close handle; aborts viaAbortSignal.
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=1route param puts LauncherApp in a mode where the per-tool Launch action sendsobserve:trueand 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
spawnObservedfailure →launchToolreturnsok:falsewith the spawn error (today's path)./streamwith an unknown/non-observed/dead pid → 404{ error }; pane shows the hint, not a spinner-forever.- Client disconnect / unmount →
reply.raw.on('close')unsubscribes;AbortSignaltears down the fetch reader. - Process exits → terminal
exitevent 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):
spawnObservedinjected fake emits synthetic data/exit → buffer fills, caps at 2000 lines / 256 KB, finalizes with exit code, evicts after grace.launchTool({observe:true})returnsoutputhandle;observe:false/absent unchanged (regression-lock).tracker.register(..., {observed:true})lists the pid butpersist()excludes it; reconcile never sees it./api/tools/launch {observe}registers + attaches;/api/tools/streamreplays tail, streams live frames, emitsexit, 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 |
new — ToolOutputBuffer 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 (4–6 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.