From f4dc6a0730ee64298c967f6b7e91f599672e6201 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 11 Aug 2026 17:32:47 +0700 Subject: [PATCH] docs(spec): design real tmux+PTY terminal to replace stream-json Run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal-started sessions can never be controlled from the dashboard (hooks are one-way, no stdin channel into an externally-spawned process) — tmux is the only mechanism that gives a second attached client real two-way control. Replaces the stream-json/chat-bubble Run feature entirely with a real tmux-backed PTY streamed via xterm.js, while leaving hook-derived session/agent data (already synced independent of the run mechanism) untouched. --- .../2026-08-11-tmux-terminal-run-design.md | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md diff --git a/docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md b/docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md new file mode 100644 index 0000000..f6cd7b2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md @@ -0,0 +1,242 @@ +# Replace "Run Claude from the browser" with a real tmux+PTY terminal + +**Status:** approved 2026-08-11. + +## Problem + +The current Run feature (`server/lib/run-spawner.js`, `server/routes/run.js`, +`client/src/components/run/*`) spawns `claude --output-format stream-json`, +parses the structured JSON event stream, and renders it as custom chat +bubbles (`RunConsole.tsx`). This has two gaps the user hit: + +1. A session started directly in a terminal (`claude`, no dashboard + involvement) can never appear in the dashboard's active-runs UI or be + controlled from it — hooks are one-way, fire-and-forget; there is no + channel to inject keystrokes into a process the dashboard didn't spawn. +2. Even for dashboard-spawned runs, the rendered UI is a re-implementation of + Claude Code's own TUI (chat bubbles, tool-call cards) rather than the real + thing — spinners, `/` command menus, permission prompts, and any other TUI + surface only exist if `RunConsole.tsx` was specifically coded to parse and + render that JSON event. + +The user wants: type `claude` for real (interactive TUI, not +`--output-format stream-json`), and still get full two-way control (start, +resume, send input, kill) from the dashboard, AND be able to drop into the +exact same live session from a real terminal at any time. + +## Approach + +**tmux is the only viable mechanism.** A dashboard-controlled process cannot +inject keystrokes into another process's stdin without owning that stdin. +tmux already solves "one pane, multiple attached clients, all synced" — +attaching a second client (the dashboard's PTY) to the same tmux session as +the user's real terminal gives real two-way control with zero custom sync +code. The dashboard both **creates** the tmux session (so Start/Resume work +as one-click actions) and can be attached to **from** a real terminal by +name, satisfying "type `claude` for real." + +This **replaces** the stream-json run mechanism entirely — no dual mode. +Cost: real TUI rendering needs a real terminal emulator in the browser +(`@xterm/xterm`), which this repo does not have today; `node-pty` is needed +server-side to get a real PTY for the attach client (a plain `child_process` +pipe is not a TTY, and `tmux attach` behaves differently — cursor +positioning, terminal size queries — without one). Both are new, justified +dependencies (no stdlib/native equivalent renders ANSI/TUI output). + +The **structured session view is not rebuilt** — `claude` still fires the +same hooks (`SessionStart`, `PreToolUse`, …) it always does regardless of how +it's invoked, so the existing session/agent/event tables and their WS +broadcasts (`session_updated`, `agent_updated`) already populate live, +independent of the terminal view. Nothing new is needed to keep them in +sync — they were never coupled to the run mechanism in the first place. + +## Scope + +**In scope:** +- tmux session lifecycle (create/attach/resume/kill) per lane, named + `ccam-lane-`. +- A dedicated WebSocket path streaming a real PTY (`node-pty` running + `tmux attach-session`) to the browser, rendered with `@xterm/xterm`. +- Replacing `RunConsole.tsx` with a `TerminalView.tsx` component. +- Reusing (not rebuilding) `RunSetup.tsx`'s cwd/model/permission-mode/effort + pickers and `RunHistory.tsx`/`ActiveRunsSwitcher`'s multi-run list, both + adjusted to the new data source. +- `ccam lanes shell` — CLI convenience to attach a real terminal to the same + named tmux session. +- `dashboard_runs` migration: drop `mode` (headless/conversation no longer + applies — a live pane is always interactive), add `tmux_session`. +- A tmux-availability check surfaced the same way the existing + "`claude` not on PATH" check is (`api.run.binary()` precedent). + +**Out of scope (explicitly):** +- Any fallback to the old stream-json mode. It is deleted, not kept behind a + flag. +- Resize handling beyond fit-to-container on load and on browser window + resize (no manual pane-splitting, no multi-pane tmux layouts). +- Any change to hook ingestion, session/agent tables, or their WS broadcasts + — they already work unmodified. +- CI running real tmux — server tests mock the tmux/PTY layer (see Testing). + +## Design + +### Session naming and lifecycle + +One tmux session per lane, name `ccam-lane-` — stable, collision-free +(numeric lane id, not a user-editable slug). + +- **Start:** `tmux has-session -t ccam-lane-` (exit code only, no + output). If absent: `tmux new-session -d -s ccam-lane- -c -- + claude [initialPrompt]` — `claude` runs directly as the pane's + command (nothing is "typed"). `argv` carries `--model`, `--permission-mode`, + `--effort` as today; an optional initial prompt is passed as a trailing + **positional** argument (not `-p`, which forces print-and-exit and closes + stdin) — `claude` treats a bare positional as the first turn's message and + stays interactive afterward, so no timing-dependent "wait then type" step + is needed. + If already present: no-op — this is the existing repo convention (adopt an + already-live server instead of double-binding; see `server/index.js`'s + port-adoption logic) applied to tmux sessions. +- **Resume:** identical, `argv` includes `--resume `. +- **Kill:** `tmux kill-session -t ccam-lane-`. This sends SIGHUP to the + pane's process group. Whether `claude` treats that as a clean shutdown + (firing `SessionEnd`) is unverified — flag for implementation to check; if + not, this repo's existing dead-session liveness reap (`server/lib/ + session-liveness.js`, referenced in `CLAUDE.md`) is the safety net that + already exists for exactly this kind of gap, no new code needed. +- **List (`GET /api/run`):** no longer a Map read. Runs `tmux list-sessions + -F '#{session_name}'`, filters the `ccam-lane-` prefix, and joins against + `dashboard_runs` rows (started_at, model, lane_id, …) for display. This + makes "which runs are active" a **computed fact from tmux state**, not + cached server memory — the same principle this repo already applies to + lane runtime up/down (`CLAUDE.md`: "computed fact, never a stored one"). A + tmux session killed by an out-of-band `kill`, OOM, or reboot self-corrects + on the next list call instead of leaving a ghost "running" row. + +### `server/lib/pty-run.js` (replaces `run-spawner.js`) + +Same exported surface where it still makes sense, so `routes/run.js`'s call +sites don't need a rewrite beyond the changed body: +- `spawnRun({laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt})` + — runs the has-session/new-session dance above, records the row via + `dashboard-runs.js`, returns `{id: tmuxSessionName, ...}`. +- `killRun(id)` — `tmux kill-session`. +- `listRuns()` — `tmux list-sessions` + DB join, as above. +- `attachStream(id, {cols, rows})` — new: spawns + `node-pty.spawn("tmux", ["attach-session", "-t", id], {cols, rows})` and + returns the PTY handle for a WS connection to pipe. +- `sendInput` / raw envelope buffering / `MAX_ENVELOPES_PER_HANDLE` / + `handles` Map / reap timers — all deleted; state lives in tmux, not this + process's memory. No 5-minute reap needed either — a tmux session survives + the dashboard restarting, by design. + +### WebSocket transport (new path, existing `/ws` untouched) + +`server/websocket.js` currently owns one `WebSocketServer({path: "/ws"})`. +A second server is added for the PTY stream, same `verifyClient` auth guard +(Host allowlist + `DASHBOARD_TOKEN`) reused as-is: + +- Path: `/ws-pty/:runId` (runId = tmux session name, validated against the + `ccam-lane-` pattern before any tmux command touches it — this + is the trust boundary: without validation, a WS client could name an + arbitrary tmux session on the host and attach to something unrelated to + this dashboard). +- **Binary frames** = raw PTY bytes, both directions (server→client is + `pty.onData`, client→server is keystrokes written straight to `pty.write`). +- **Text frames** = JSON control messages, distinguishable from binary frames + natively by `ws` — `{"type":"resize","cols":N,"rows":N}` on window + resize/mount, `{"type":"exit","code":N}` sent once when the attach PTY + closes (pane process exited or session was killed). +- One `node-pty` attach process per WS connection — multiple browser tabs + attach as independent tmux clients to the same session; tmux itself keeps + them in sync (this is exactly the tmux feature the whole design leans on). + Closing a tab just ends that one attach client; the tmux session and the + `claude` process underneath are untouched (detach-safe by construction). + +### Client + +- **New `client/src/components/run/TerminalView.tsx`** replaces + `RunConsole.tsx`: mounts `@xterm/xterm` + `@xterm/addon-fit`, opens + `/ws-pty/`, writes incoming binary frames to the terminal, writes + terminal keystrokes (`term.onData`) to the WS as binary frames, sends a + `resize` control message on mount and on `ResizeObserver` fire. +- **`RunSetup.tsx`** keeps its cwd/model/permission-mode/effort/resume + pickers and file-mention autocomplete as-is; the prompt textarea becomes + optional ("send this once the terminal is up" instead of "the one-shot + headless prompt"); its submit calls the same `api.lanes.action(..., + "start", {...})` / `api.run.start()` shape, just against the new backend. +- **`RunHistory.tsx` / `ActiveRunsSwitcher`** keep their list/switch/kill + UI; the data they render (`api.run.list()`, `api.run.history()`) changes + shape server-side but not their consumption pattern. +- **`client/src/lib/api.ts`**: `api.run.send(id, text)` is removed — the + initial prompt now travels as a positional `spawnRun` argument (see + above), and every input after that goes through the WS binary channel + directly from `TerminalView`, not a REST call. +- **Removed:** `useRunStream` hook (built for the envelope array this design + no longer produces), `RunConsole.tsx`. `server/lib/stream-json-parser.js` + and the envelope types in `client/src/lib/types.ts` are removed too, after + a grep confirms no other caller depends on them (SessionDetail's + transcript viewer reads pre-ingested JSONL from `~/.claude`, a completely + separate code path — expected to be unaffected, but verify before + deleting). + +### `ccam lanes shell` + +`bin/ccam.js`, same dispatch pattern as the other `lanes` subcommands +(`rest[0] === "shell"` → `cmdLanesShell`). Resolves the lane from `cwd` the +same way `ccam stage` does, computes `ccam-lane-`, and `execve`s (replace +the current process image, not a child — so Ctrl-C/signals behave like a +normal terminal command) `tmux new-session -A -s ccam-lane- -c +`. `-A` creates-or-attaches, so this is the exact same idempotent +behavior as clicking Start on the dashboard. The user types `claude` +themselves inside — this command's only job is getting them into the right +named session, nothing about `claude` itself. + +### Dependencies + +- Server: `node-pty` (native module, same operational category as + `better-sqlite3` — needs a build toolchain or prebuilt binary; this repo + already documents that tradeoff for `better-sqlite3` in `INSTALL.md`/ + `SETUP.md`, follow the same pattern for `node-pty`). +- Client: `@xterm/xterm`, `@xterm/addon-fit`. +- System: `tmux` on the host running the server. Not installable via npm — + add a startup/on-demand check (`which tmux` or `tmux -V`) mirroring the + existing `api.run.binary()` "claude not on PATH" banner pattern, surfaced + in the UI before Start is attempted. Docker image (`Dockerfile`, alpine + base) needs `RUN apk add --no-cache tmux` added. + +### `dashboard_runs` migration + +Drop `mode` (headless/conversation distinction no longer exists — every run +is a live interactive pane). Add `tmux_session TEXT`. Everything else +(`session_id`, `model`, `permission_mode`, `effort`, `resume_session_id`, +`prompt_preview`, `status`, `exit_code`, `started_at`, `ended_at`, `lane_id`) +is unchanged — these are still meaningful metadata about the `claude` +invocation regardless of transport. + +## Testing + +- **Server:** unit tests for `pty-run.js` mock `node-pty` and the + `child_process`/`execFile` calls used for `tmux has-session` / + `new-session` / `kill-session` / `list-sessions` (matching this repo's + existing pattern in `worktree.js`'s tests, which mock `execFile("git", + ...)` the same way) — no real tmux exec in the suite. CI has no `tmux` + installed (confirmed: no existing CI workflow, alpine Docker base lacks + it), so this mocking is required, not optional. +- **Client:** `TerminalView.test.tsx` mocks the WS connection and asserts + binary frames get written to a mocked `xterm` instance; `xterm.js` needs a + canvas — check whether the existing jsdom test setup handles this or + needs `@xterm/xterm`'s documented headless/test workaround before writing + the test. +- `screens.snapshot.test.tsx` will need regenerating after Workspace's run + UI changes — review the diff, don't blindly accept it, per this repo's + testing policy. + +## Verify + +`npm run test:server`, `npm run test:client` (including the regenerated +snapshot), and a manual click-through: install `tmux` locally, Start a run +from Workspace, confirm the real Claude Code TUI renders in the browser, +type in the browser and confirm it reaches the pane, run `ccam lanes shell` +from a real terminal for the same lane and confirm it drops into the exact +same live session, kill from the dashboard and confirm the tmux session and +row both clear.