Files
Claude-Code-Monitor/docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md
T
nntrivi2001 00f6338d4c docs: bring plan and spec into the tmux-terminal-run worktree
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.
2026-08-12 09:25:34 +07:00

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:

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