# 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-<id>`.
- 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-<lane.id>` — stable, collision-free
(numeric lane id, not a user-editable slug).

- **Start:** `tmux has-session -t ccam-lane-<id>` (exit code only, no
  output). If absent: `tmux new-session -d -s ccam-lane-<id> -c <lane.cwd> --
  claude <argv...> [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 <session_id>`.
- **Kill:** `tmux kill-session -t ccam-lane-<id>`. 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-<numeric id>` 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/<runId>`, 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-<id>`, 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-<id> -c
<lane.cwd>`. `-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.
