249 lines
13 KiB
Markdown
249 lines
13 KiB
Markdown
# 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 (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.
|