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

249 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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 (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:
```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;
}
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/launch`** — `launchBodySchema` 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:
```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" }
```
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` | **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 (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.