# 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.