00f6338d4c
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.
243 lines
13 KiB
Markdown
243 lines
13 KiB
Markdown
# 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.
|