These were committed on main's local history but this worktree branched from origin/main, which doesn't have them yet — copying the files in so subagent-driven-development has a plan to read from this branch.
13 KiB
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:
- 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. - 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 ifRunConsole.tsxwas 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-ptyrunningtmux attach-session) to the browser, rendered with@xterm/xterm. - Replacing
RunConsole.tsxwith aTerminalView.tsxcomponent. - Reusing (not rebuilding)
RunSetup.tsx's cwd/model/permission-mode/effort pickers andRunHistory.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_runsmigration: dropmode(headless/conversation no longer applies — a live pane is always interactive), addtmux_session.- A tmux-availability check surfaced the same way the existing
"
claudenot 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]—clauderuns directly as the pane's command (nothing is "typed").argvcarries--model,--permission-mode,--effortas today; an optional initial prompt is passed as a trailing positional argument (not-p, which forces print-and-exit and closes stdin) —claudetreats 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; seeserver/index.js's port-adoption logic) applied to tmux sessions. - Resume: identical,
argvincludes--resume <session_id>. - Kill:
tmux kill-session -t ccam-lane-<id>. This sends SIGHUP to the pane's process group. Whetherclaudetreats that as a clean shutdown (firingSessionEnd) is unverified — flag for implementation to check; if not, this repo's existing dead-session liveness reap (server/lib/ session-liveness.js, referenced inCLAUDE.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. Runstmux list-sessions -F '#{session_name}', filters theccam-lane-prefix, and joins againstdashboard_runsrows (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-bandkill, 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 viadashboard-runs.js, returns{id: tmuxSessionName, ...}.killRun(id)—tmux kill-session.listRuns()—tmux list-sessions+ DB join, as above.attachStream(id, {cols, rows})— new: spawnsnode-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/handlesMap / 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 theccam-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 topty.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-ptyattach 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 theclaudeprocess underneath are untouched (detach-safe by construction).
Client
- New
client/src/components/run/TerminalView.tsxreplacesRunConsole.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 aresizecontrol message on mount and onResizeObserverfire. RunSetup.tsxkeeps 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 sameapi.lanes.action(..., "start", {...})/api.run.start()shape, just against the new backend.RunHistory.tsx/ActiveRunsSwitcherkeep 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 positionalspawnRunargument (see above), and every input after that goes through the WS binary channel directly fromTerminalView, not a REST call.- Removed:
useRunStreamhook (built for the envelope array this design no longer produces),RunConsole.tsx.server/lib/stream-json-parser.jsand the envelope types inclient/src/lib/types.tsare 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 execves (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 asbetter-sqlite3— needs a build toolchain or prebuilt binary; this repo already documents that tradeoff forbetter-sqlite3inINSTALL.md/SETUP.md, follow the same pattern fornode-pty). - Client:
@xterm/xterm,@xterm/addon-fit. - System:
tmuxon the host running the server. Not installable via npm — add a startup/on-demand check (which tmuxortmux -V) mirroring the existingapi.run.binary()"claude not on PATH" banner pattern, surfaced in the UI before Start is attempted. Docker image (Dockerfile, alpine base) needsRUN apk add --no-cache tmuxadded.
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.jsmocknode-ptyand thechild_process/execFilecalls used fortmux has-session/new-session/kill-session/list-sessions(matching this repo's existing pattern inworktree.js's tests, which mockexecFile("git", ...)the same way) — no real tmux exec in the suite. CI has notmuxinstalled (confirmed: no existing CI workflow, alpine Docker base lacks it), so this mocking is required, not optional. - Client:
TerminalView.test.tsxmocks the WS connection and asserts binary frames get written to a mockedxterminstance;xterm.jsneeds 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.tsxwill 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.