moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
# 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.