diff --git a/docs/superpowers/plans/2026-08-12-tmux-terminal-run.md b/docs/superpowers/plans/2026-08-12-tmux-terminal-run.md new file mode 100644 index 0000000..2bee704 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-tmux-terminal-run.md @@ -0,0 +1,2188 @@ +# tmux + PTY Terminal Run — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the dashboard's "Run Claude from the browser" feature (spawns `claude --output-format stream-json`, renders custom chat bubbles) with a real tmux-backed PTY streamed to the browser via `xterm.js`, giving full two-way interactivity (type, resume, kill) from both the dashboard and a real terminal attached to the same named tmux session. + +**Architecture:** One tmux session per lane (`ccam-lane-`), created/attached/killed via `tmux` CLI calls from a new `server/lib/tmux.js`. `server/lib/pty-run.js` owns the run lifecycle (Start/Resume/Kill/List — List is now a computed read of `tmux list-sessions`, not cached in-memory state) and a `node-pty`-backed attach used per WebSocket connection on a new `/ws-pty/:runId` path. The client's `TerminalView.tsx` (`@xterm/xterm`) replaces `RunConsole.tsx`. Hook-derived session/agent data is untouched — it already updates independent of the run transport. + +**Tech Stack:** Node.js/Express, `ws`, `node-pty` (new), `better-sqlite3`, React, `@xterm/xterm` + `@xterm/addon-fit` (new), `node:test`, Vitest. + +## Global Constraints + +- No fallback to the old stream-json mode — it is deleted, not flagged off (spec: "Approach"). +- The `/ws-pty/:runId` path must reuse the exact same `verifyClient` auth (Host allowlist + `DASHBOARD_TOKEN`) as the existing `/ws` path (spec: "WebSocket transport"). +- `runId` (tmux session name) must be validated against `^ccam-lane-\d+$` before any tmux command touches it — untrusted input must never reach a shell-adjacent command unvalidated (spec: "WebSocket transport" trust boundary). +- All tmux/`node-pty` invocations use `execFile`/`node-pty.spawn` with an explicit argument array — never a concatenated shell string (matches this repo's existing rule for git in `server/lib/worktree.js`, `CLAUDE.md`). +- Server tests must not exec real tmux (no CI has it installed) — mock the `tmux.js` module via an injectable exec seam, same style as `run-spawner.js`'s existing `__injectChildForTest`/`__reset` test seams. +- Every new/modified `.js`/`.ts`/`.tsx` file keeps this repo's required file header (file overview + `@author Nguyễn Ngọc Trí Vĩ ` — `.claude/skills/file-headers/`). +- `npm run test:server` and `npm run test:client` must stay green throughout; regenerate `screens.snapshot.test.tsx` only after reviewing the diff (repo testing policy). + +--- + +### Task 1: Add dependencies, Docker tmux, and the DB migration + +**Files:** +- Modify: `package.json` (root — add `node-pty`) +- Modify: `client/package.json` (add `@xterm/xterm`, `@xterm/addon-fit`) +- Modify: `Dockerfile:35-47` (stage 3 — install `tmux`) +- Modify: `server/db.js:248-262` (schema), `server/db.js:497` area (migration probe) + +**Interfaces:** +- Produces: `dashboard_runs.tmux_session TEXT` column, available to every later server task. + +- [ ] **Step 1: Add server dependency** + +```bash +npm install node-pty@^1.0.0 +``` + +- [ ] **Step 2: Add client dependencies** + +```bash +cd client && npm install @xterm/xterm@^5.5.0 @xterm/addon-fit@^0.10.0 && cd .. +``` + +- [ ] **Step 3: Install tmux in the Docker runtime stage** + +Edit `Dockerfile`, stage 3 (the `FROM node:22-alpine` block, before `WORKDIR /app`'s `COPY` lines): + +```dockerfile +# ── Stage 3: Production runtime ─────────────────────────── +FROM node:22-alpine +WORKDIR /app + +# Runs Claude Code sessions inside a named tmux session per lane so both the +# dashboard (via node-pty attach) and a real terminal can share one live pane. +RUN apk add --no-cache tmux + +COPY --from=server-deps /app/node_modules ./node_modules/ +``` + +- [ ] **Step 4: Add the `tmux_session` column and drop the now-unused `mode` NOT NULL requirement** + +In `server/db.js`, find the `dashboard_runs` table definition (around line 248) and change: + +```sql + CREATE TABLE IF NOT EXISTS dashboard_runs ( + id TEXT PRIMARY KEY, + session_id TEXT, + mode TEXT NOT NULL, + cwd TEXT NOT NULL, +``` + +to: + +```sql + CREATE TABLE IF NOT EXISTS dashboard_runs ( + id TEXT PRIMARY KEY, + session_id TEXT, + mode TEXT, + cwd TEXT NOT NULL, +``` + +(`mode` stops being written by new code — see Task 4 — but stays nullable rather than dropped: SQLite's `ALTER TABLE ... DROP COLUMN` support varies across the `better-sqlite3`/`node:sqlite` fallback this repo supports, per `server/compat-sqlite.js`, and old rows already have real `headless`/`conversation` values worth keeping for history display. Making it nullable, not dropping it, is the lower-risk migration.) + +Then add the new column via the same probe-then-`ALTER` pattern already used for `lane_id` right below it (find the `// Migrate: label a dashboard run with the lane it was started through` block around line 489-497 and add directly after it): + +```javascript +// Migrate: tmux session name backing this run (tmux+PTY terminal design, +// 2026-08-11). Additive and nullable — historical rows spawned via the old +// stream-json mode have no tmux session and are display-only history now. +try { + db.prepare("SELECT tmux_session FROM dashboard_runs LIMIT 1").get(); +} catch { + db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tmux_session TEXT").run(); +} +``` + +- [ ] **Step 5: Verify the migration runs clean on a fresh DB and an existing one** + +```bash +rm -f /tmp/ccam-migration-check.db +DASHBOARD_DB_PATH=/tmp/ccam-migration-check.db node -e "require('./server/db')" && echo OK +node --test server/__tests__/*.test.js 2>&1 | tail -20 +``` + +Expected: `OK`, and the existing suite still passes (nothing else touches `dashboard_runs` shape yet). + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json client/package.json client/package-lock.json Dockerfile server/db.js +git commit -m "chore: add node-pty/xterm deps, tmux in Docker, dashboard_runs.tmux_session column" +``` + +--- + +### Task 2: `server/lib/tmux.js` — low-level tmux command wrapper + +**Files:** +- Create: `server/lib/tmux.js` +- Test: `server/__tests__/tmux.test.js` + +**Interfaces:** +- Produces (consumed by Task 3): + - `hasSession(name: string): boolean` + - `newSession({name, cwd, argv}: {name: string, cwd: string, argv: string[]}): void` — `argv` is the command+args to run as the pane's process (e.g. `["claude", "--model", "opus"]`) + - `killSession(name: string): void` — never throws if the session is already gone + - `listSessions(prefix: string): string[]` — session names starting with `prefix` + - `isTmuxAvailable(): boolean` + - `__setExecImpl(fn)` / `__reset()` — test seam, same pattern as `run-spawner.js`'s `__injectChildForTest`/`__reset` + +- [ ] **Step 1: Write the failing tests** + +```javascript +// server/__tests__/tmux.test.js +/** + * @file tmux.test.js + * @description Unit tests for the tmux command wrapper. Injects a fake exec + * implementation so the suite never shells out to a real `tmux` binary (CI + * has none installed). + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const tmux = require("../lib/tmux"); + +describe("tmux wrapper", () => { + beforeEach(() => { + tmux.__reset(); + }); + + it("hasSession returns true when execFileSync exits 0", () => { + tmux.__setExecImpl(() => ""); + assert.equal(tmux.hasSession("ccam-lane-1"), true); + }); + + it("hasSession returns false when execFileSync throws", () => { + tmux.__setExecImpl(() => { + const e = new Error("no such session"); + e.status = 1; + throw e; + }); + assert.equal(tmux.hasSession("ccam-lane-1"), false); + }); + + it("newSession builds the correct argv", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + return ""; + }); + tmux.newSession({ name: "ccam-lane-1", cwd: "/tmp/repo", argv: ["claude", "--model", "opus"] }); + assert.deepEqual(calls[0], [ + "new-session", + "-d", + "-s", + "ccam-lane-1", + "-c", + "/tmp/repo", + "--", + "claude", + "--model", + "opus", + ]); + }); + + it("killSession never throws when the session is already gone", () => { + tmux.__setExecImpl(() => { + const e = new Error("no such session"); + e.status = 1; + throw e; + }); + assert.doesNotThrow(() => tmux.killSession("ccam-lane-1")); + }); + + it("listSessions filters by prefix and ignores unrelated sessions", () => { + tmux.__setExecImpl(() => "ccam-lane-1\nccam-lane-2\nsome-other-session\n"); + assert.deepEqual(tmux.listSessions("ccam-lane-"), ["ccam-lane-1", "ccam-lane-2"]); + }); + + it("listSessions returns [] when tmux has no sessions at all (exit 1)", () => { + tmux.__setExecImpl(() => { + const e = new Error("no server running"); + e.status = 1; + throw e; + }); + assert.deepEqual(tmux.listSessions("ccam-lane-"), []); + }); + + it("isTmuxAvailable reflects whether the binary resolves on PATH", () => { + tmux.__setExecImpl(() => "tmux 3.4"); + assert.equal(tmux.isTmuxAvailable(), true); + tmux.__setExecImpl(() => { + throw new Error("ENOENT"); + }); + assert.equal(tmux.isTmuxAvailable(), false); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +node --test server/__tests__/tmux.test.js +``` + +Expected: FAIL — `Cannot find module '../lib/tmux'`. + +- [ ] **Step 3: Implement `server/lib/tmux.js`** + +```javascript +/** + * @file tmux.js + * @description Thin wrapper around the `tmux` CLI for the terminal-run + * feature. Every dashboard-managed session is named `ccam-lane-` (see + * `pty-run.js`) so a real terminal can attach to the exact same session with + * `tmux attach -t ccam-lane-` (or `ccam lanes shell`). Never builds a + * shell string — every call is `execFileSync("tmux", [...argv])` with an + * explicit argument array (matches this repo's rule for git in worktree.js). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFileSync } = require("node:child_process"); + +// Test seam: swap the exec implementation so unit tests never invoke a real +// tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style. +let execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" }); + +function __setExecImpl(fn) { + execImpl = fn; +} +function __reset() { + execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" }); +} + +function hasSession(name) { + try { + execImpl(["has-session", "-t", name]); + return true; + } catch { + return false; + } +} + +/** + * Create a detached tmux session running `argv` as the pane's command. Throws + * if tmux itself fails to start (caller decides how to surface that). + */ +function newSession({ name, cwd, argv }) { + execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]); +} + +/** Idempotent — a session that's already gone is not an error. */ +function killSession(name) { + try { + execImpl(["kill-session", "-t", name]); + } catch { + /* already gone */ + } +} + +/** Session names starting with `prefix`. Empty array if tmux has no server running at all. */ +function listSessions(prefix) { + let out; + try { + out = execImpl(["list-sessions", "-F", "#{session_name}"]); + } catch { + return []; + } + return out + .split("\n") + .map((s) => s.trim()) + .filter((s) => s && s.startsWith(prefix)); +} + +function isTmuxAvailable() { + try { + execImpl(["-V"]); + return true; + } catch { + return false; + } +} + +module.exports = { + hasSession, + newSession, + killSession, + listSessions, + isTmuxAvailable, + __setExecImpl, + __reset, +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +node --test server/__tests__/tmux.test.js +``` + +Expected: PASS, all 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add server/lib/tmux.js server/__tests__/tmux.test.js +git commit -m "feat(run): add tmux command wrapper with an injectable exec seam" +``` + +--- + +### Task 3: `server/lib/pty-run.js` — run lifecycle (Start/Resume/Kill/List) + +**Files:** +- Create: `server/lib/pty-run.js` +- Test: `server/__tests__/pty-run.test.js` + +**Interfaces:** +- Consumes: `tmux.js`'s `hasSession`, `newSession`, `killSession`, `listSessions` (Task 2); `dashboard-runs.js`'s `recordRun`/`getRun`/`listRuns` (unchanged — Task 1's migration already made them tolerant of the new/nullable columns). +- Produces (consumed by Task 4's route rewrite and Task 5's WS attach): + - `spawnRun({laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt}): {id: string, ...}` — `id` is the tmux session name. + - `killRun(id: string): boolean` + - `listRuns(): Array` — computed fresh from `tmux.listSessions("ccam-lane-")` each call, no cached Map. + - `getRun(id: string): PublicRun | null` + - `laneIdFromRunId(id: string): number | null` — parses `ccam-lane-` back to the numeric lane id; used by Task 4's WS runId validation. + +Where `PublicRun` is `{id, laneId, cwd, model, permissionMode, effort, resumeSessionId, status: "running"|"gone", startedAt, sessionId}` (`sessionId`/`startedAt` come from the `dashboard_runs` row via `dashboard-runs.getRun(id)`, since tmux itself doesn't know Claude's session id). + +- [ ] **Step 1: Write the failing tests** + +```javascript +// server/__tests__/pty-run.test.js +/** + * @file pty-run.test.js + * @description Unit tests for the tmux-backed run lifecycle. Injects a fake + * tmux exec implementation (via tmux.js's test seam) so no real tmux binary + * is invoked. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, beforeEach, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-")); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); + +const tmux = require("../lib/tmux"); +const pty = require("../lib/pty-run"); + +describe("pty-run", () => { + after(() => { + try { + fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch { + /* best-effort */ + } + }); + + beforeEach(() => { + tmux.__reset(); + }); + + it("spawnRun creates a new tmux session named ccam-lane- when none exists", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + if (args[0] === "has-session") { + const e = new Error("no such session"); + e.status = 1; + throw e; + } + return ""; + }); + const handle = pty.spawnRun({ laneId: 42, cwd: "/tmp/repo", model: "opus" }); + assert.equal(handle.id, "ccam-lane-42"); + const newSessionCall = calls.find((c) => c[0] === "new-session"); + assert.ok(newSessionCall, "expected a new-session call"); + assert.deepEqual(newSessionCall.slice(0, 6), ["new-session", "-d", "-s", "ccam-lane-42", "-c", "/tmp/repo"]); + assert.ok(newSessionCall.includes("claude")); + assert.ok(newSessionCall.includes("--model")); + assert.ok(newSessionCall.includes("opus")); + }); + + it("spawnRun is a no-op (adopts) when the tmux session already exists", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + return ""; // has-session succeeds → already running + }); + const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" }); + assert.equal(handle.id, "ccam-lane-7"); + assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session"); + }); + + it("spawnRun with resumeSessionId passes --resume in argv", () => { + let newSessionArgv = null; + tmux.__setExecImpl((args) => { + if (args[0] === "has-session") { + const e = new Error("gone"); + e.status = 1; + throw e; + } + if (args[0] === "new-session") newSessionArgv = args; + return ""; + }); + pty.spawnRun({ laneId: 1, cwd: "/tmp/repo", resumeSessionId: "abc12345" }); + assert.ok(newSessionArgv.includes("--resume")); + assert.ok(newSessionArgv.includes("abc12345")); + }); + + it("spawnRun appends a positional initial prompt after argv flags", () => { + let newSessionArgv = null; + tmux.__setExecImpl((args) => { + if (args[0] === "has-session") { + const e = new Error("gone"); + e.status = 1; + throw e; + } + if (args[0] === "new-session") newSessionArgv = args; + return ""; + }); + pty.spawnRun({ laneId: 3, cwd: "/tmp/repo", initialPrompt: "fix the bug" }); + assert.equal(newSessionArgv[newSessionArgv.length - 1], "fix the bug"); + }); + + it("killRun calls tmux kill-session with the run id", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + return ""; + }); + assert.equal(pty.killRun("ccam-lane-5"), true); + assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "ccam-lane-5")); + }); + + it("listRuns reflects live tmux-session state, not cached memory", () => { + tmux.__setExecImpl((args) => { + if (args[0] === "list-sessions") return "ccam-lane-1\nccam-lane-2\n"; + return ""; + }); + const first = pty.listRuns(); + assert.deepEqual( + first.map((r) => r.id).sort(), + ["ccam-lane-1", "ccam-lane-2"] + ); + + // Session killed out-of-band (not through killRun) — next list() call + // must self-correct, proving state is computed, not stored. + tmux.__setExecImpl((args) => { + if (args[0] === "list-sessions") return "ccam-lane-1\n"; + return ""; + }); + const second = pty.listRuns(); + assert.deepEqual( + second.map((r) => r.id), + ["ccam-lane-1"] + ); + }); + + it("laneIdFromRunId parses the numeric lane id back out", () => { + assert.equal(pty.laneIdFromRunId("ccam-lane-42"), 42); + assert.equal(pty.laneIdFromRunId("not-a-run-id"), null); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +node --test server/__tests__/pty-run.test.js +``` + +Expected: FAIL — `Cannot find module '../lib/pty-run'`. + +- [ ] **Step 3: Implement `server/lib/pty-run.js`** + +```javascript +/** + * @file pty-run.js + * @description Owns the tmux-backed run lifecycle for the dashboard's + * terminal-run feature: Start (create-or-adopt), Resume (`--resume`), Kill, + * and List. Unlike the old run-spawner.js, there is no in-memory handle Map — + * `listRuns`/`getRun` are computed fresh from `tmux list-sessions` on every + * call, the same "computed fact, never a stored one" principle this repo + * already applies to lane runtime up/down (see CLAUDE.md). A session killed + * out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects + * on the next read instead of leaving a ghost "running" row. + * + * Every session is named `ccam-lane-` so a real terminal can attach + * to the exact same session (`tmux attach -t ccam-lane-`, or + * `ccam lanes shell`) — that's the whole point: the dashboard both creates + * the session (one-click Start/Resume) and is just one of possibly several + * attached clients tmux already keeps in sync. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +const tmux = require("./tmux"); + +let dashboardRuns = null; +try { + dashboardRuns = require("./dashboard-runs"); +} catch { + /* db-less environment, skip persistence */ +} + +const RUN_ID_RE = /^ccam-lane-(\d+)$/; +const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); +const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); + +function runIdForLane(laneId) { + return `ccam-lane-${laneId}`; +} + +function laneIdFromRunId(id) { + const m = typeof id === "string" ? id.match(RUN_ID_RE) : null; + return m ? Number(m[1]) : null; +} + +function makeErr(code, message) { + const err = new Error(message); + err.code = code; + return err; +} + +/** + * Build the pane's command argv. Unlike the old stream-json spawner there is + * no headless/conversation split — every run is a live interactive pane, so + * an initial prompt (when given) is a trailing POSITIONAL argument: `claude` + * treats a bare positional as the first turn's message and stays interactive + * afterward (unlike `-p`, which forces print-and-exit and closes stdin). + */ +function buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) { + const argv = ["claude"]; + argv.push("--permission-mode", permissionMode || "acceptEdits"); + if (model) argv.push("--model", model); + if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort); + if (resumeSessionId) argv.push("--resume", resumeSessionId); + if (initialPrompt) argv.push(initialPrompt); + return argv; +} + +/** + * @param {object} args + * @param {number} args.laneId + * @param {string} args.cwd + * @param {string} [args.model] + * @param {string} [args.permissionMode] + * @param {string} [args.effort] + * @param {string} [args.resumeSessionId] + * @param {string} [args.initialPrompt] + */ +function spawnRun(args) { + const { laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt } = args || {}; + if (typeof laneId !== "number" || !Number.isInteger(laneId)) { + throw makeErr("EBADLANE", "laneId must be an integer"); + } + if (typeof cwd !== "string" || !cwd) { + throw makeErr("EBADCWD", "cwd is required"); + } + if (permissionMode != null && !ALLOWED_PERMISSION_MODES.has(permissionMode)) { + throw makeErr("EBADMODE", `permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}`); + } + if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) { + throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`); + } + if (resumeSessionId != null && (typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId))) { + throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id"); + } + + const id = runIdForLane(laneId); + const startedAt = Date.now(); + + if (!tmux.hasSession(id)) { + const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }); + tmux.newSession({ name: id, cwd, argv }); + if (dashboardRuns) { + dashboardRuns.recordRun({ + id, + sessionId: resumeSessionId || null, + mode: null, + cwd, + model: model || null, + permissionMode: permissionMode || "acceptEdits", + effort: effort || null, + resumeSessionId: resumeSessionId || null, + prompt: initialPrompt || "", + status: "running", + startedAt, + endedAt: null, + exitCode: null, + laneId, + }); + } + } + // Already running: adopt silently, same convention as this repo's server + // port-adoption logic — no error, no duplicate session. + + return getRun(id); +} + +function killRun(id) { + if (!id || !tmux.hasSession(id)) return false; + tmux.killSession(id); + if (dashboardRuns) { + dashboardRuns.patchRun({ id, status: "killed", endedAt: Date.now() }); + } + return true; +} + +function publicRun(id) { + const laneId = laneIdFromRunId(id); + const live = tmux.hasSession(id); + const row = dashboardRuns ? dashboardRuns.getRun(id) : null; + return { + id, + laneId, + status: live ? "running" : "gone", + cwd: row?.cwd || null, + model: row?.model || null, + permissionMode: row?.permission_mode || null, + effort: row?.effort || null, + resumeSessionId: row?.resume_session_id || null, + sessionId: row?.session_id || null, + startedAt: row?.started_at || null, + }; +} + +function getRun(id) { + if (!id) return null; + return publicRun(id); +} + +/** Computed fresh from tmux state every call — see file header. */ +function listRuns() { + return tmux.listSessions("ccam-lane-").map(publicRun); +} + +module.exports = { + spawnRun, + killRun, + getRun, + listRuns, + laneIdFromRunId, + runIdForLane, +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +node --test server/__tests__/pty-run.test.js +``` + +Expected: PASS, all 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add server/lib/pty-run.js server/__tests__/pty-run.test.js +git commit -m "feat(run): add tmux-backed run lifecycle (spawn/kill/list computed from tmux state)" +``` + +--- + +### Task 4: `server/routes/run.js` — rewrite for the tmux backend + +**Files:** +- Modify: `server/routes/run.js` (rewrite `POST /`, `GET /`, `DELETE /:id`; remove `POST /:id/message`; keep `GET /cwds`, `GET /files`, `sameOriginGuard`, `sanitiseCwd` unchanged) +- Modify: `server/routes/run.js` — rename `GET /binary` to also report tmux, or add `GET /tmux` (see Step 3) +- Test: `server/__tests__/run.test.js` (rewrite the spawn/kill/list cases; delete the `/message` cases) + +**Interfaces:** +- Consumes: `pty-run.js`'s `spawnRun`, `killRun`, `getRun`, `listRuns` (Task 3); `tmux.js`'s `isTmuxAvailable` (Task 2). +- Produces: `POST /api/run` now requires `laneId` (a terminal run only makes sense scoped to a lane's cwd — there is no more "spawn anywhere headless" use case now that every run is a live interactive pane a human is expected to look at). + +- [ ] **Step 1: Update the route imports and remove the old spawner** + +In `server/routes/run.js`, replace: + +```javascript +const runs = require("../lib/run-spawner"); +``` + +with: + +```javascript +const runs = require("../lib/pty-run"); +const tmux = require("../lib/tmux"); +``` + +- [ ] **Step 2: Replace `GET /`, `POST /`, and `DELETE /:id`** + +Replace the block from `router.get("/", ...)` (line 98) through the end of `router.post("/", ...)` (line 310) with: + +```javascript +router.get("/", (_req, res) => { + res.json({ items: runs.listRuns() }); +}); + +router.post("/", (req, res) => { + const body = req.body || {}; + const laneId = Number.parseInt(String(body.laneId ?? ""), 10); + if (!Number.isInteger(laneId)) { + return res.status(400).json({ error: { code: "EBADLANE", message: "laneId is required" } }); + } + let cwd; + try { + cwd = sanitiseCwd(body.cwd); + } catch (err) { + return res.status(400).json({ error: { code: err.code, message: err.message } }); + } + try { + const handle = runs.spawnRun({ + laneId, + cwd, + model: typeof body.model === "string" && body.model ? body.model : null, + permissionMode: + typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode) + ? body.permissionMode + : "acceptEdits", + effort: typeof body.effort === "string" && body.effort ? body.effort : null, + resumeSessionId: + typeof body.resumeSessionId === "string" && body.resumeSessionId ? body.resumeSessionId : null, + initialPrompt: typeof body.initialPrompt === "string" ? body.initialPrompt : "", + }); + return res.status(201).json(handle); + } catch (err) { + if (err.code && err.code.startsWith("E")) { + return res.status(400).json({ error: { code: err.code, message: err.message } }); + } + return res.status(500).json({ error: { code: "EINTERNAL", message: err.message } }); + } +}); +``` + +And replace `router.delete("/:id", ...)` (line 338) — unchanged in shape, but now calling the new module (already covered since `runs` was reassigned above; no further edit needed there). Delete the `router.post("/:id/message", ...)` block (lines 312-325) entirely — there is no more stdin-envelope input path; all input after Start goes through the WS binary channel (Task 5/8). + +Also delete `router.get("/:id", ...)` (lines 327-336, the `?envelopes=1` handler) — replace with a plain single-run lookup: + +```javascript +router.get("/:id", (req, res) => { + const handle = runs.getRun(req.params.id); + if (!handle || handle.status === "gone") { + return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } }); + } + return res.json(handle); +}); +``` + +- [ ] **Step 3: Add a tmux-availability check next to the existing `claude`-on-PATH check** + +Directly below the existing `router.get("/binary", ...)` handler (line 247-261, unchanged), add: + +```javascript +router.get("/tmux", (_req, res) => { + res.json({ available: tmux.isTmuxAvailable() }); +}); +``` + +- [ ] **Step 4: Update `GET /history` to stop cross-referencing the deleted in-memory Map** + +In the `router.get("/history", ...)` handler (line 115), replace the "cross-reference with live handles" block: + +```javascript + // Cross-reference with live handles so the UI can mark which history + // entries are still attached / running. + const liveIds = new Set(); + for (const h of runs.listRuns()) { + if (h.id && (h.status === "running" || h.status === "spawning")) liveIds.add(h.id); + } + res.json({ + items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })), + }); +``` + +with: + +```javascript + const liveIds = new Set(runs.listRuns().map((h) => h.id)); + res.json({ + items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })), + }); +``` + +(same intent, just reading the new module's already-computed live list — `runs.listRuns()` now only ever returns sessions tmux confirms exist, so every entry is inherently "live"; no more `status === "running"/"spawning"` filter needed.) + +- [ ] **Step 5: Update the file header's mode description** + +Replace the file's `@description` (lines 3-6) to no longer describe headless/conversation: + +```javascript + * @description HTTP routes for the dashboard's terminal-run feature. Starts, + * resumes, kills, and lists tmux-backed `claude` sessions (one per lane), + * streamed to the client over a dedicated WebSocket path (see + * server/websocket.js `/ws-pty/:runId`) rather than this REST surface. +``` + +- [ ] **Step 6: Rewrite `server/__tests__/run.test.js`'s spawn/message/kill/list cases** + +The file's `makeFakeChild()` helper and every test that depends on the old stream-json child process model (envelope injection, `/message` POST) no longer apply — those tests are for Task 8/9's deleted client concepts and this task's deleted route, not this module. Replace the whole file with: + +```javascript +// server/__tests__/run.test.js +/** + * @file run.test.js + * @description Route tests for the terminal-run feature: same-origin guard, + * laneId/cwd validation, spawn/kill/list against a mocked tmux backend. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, before, after, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); +const http = require("node:http"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-route-test-")); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); + +const { createApp } = require("../index"); +const tmux = require("../lib/tmux"); + +let server; +let BASE; + +function fetchJson(p, opts = {}) { + return new Promise((resolve, reject) => { + const url = new URL(p, BASE); + const headers = { ...(opts.headers || {}) }; + let body; + if (opts.body !== undefined) { + body = Buffer.from(JSON.stringify(opts.body)); + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = body.length; + } + const req = http.request( + { hostname: url.hostname, port: url.port, path: url.pathname + url.search, method: opts.method || "GET", headers }, + (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + parsed = raw; + } + resolve({ status: res.statusCode, body: parsed }); + }); + } + ); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +describe("/api/run", () => { + before(async () => { + const app = createApp(); + server = http.createServer(app); + await new Promise((r) => server.listen(0, r)); + BASE = `http://127.0.0.1:${server.address().port}`; + }); + + after(async () => { + await new Promise((r) => server.close(r)); + try { + fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch { + /* best-effort */ + } + }); + + beforeEach(() => { + tmux.__reset(); + }); + + it("rejects cross-origin browser requests", async () => { + const { status, body } = await fetchJson("/api/run", { headers: { Origin: "http://evil.example.com" } }); + assert.equal(status, 403); + assert.equal(body.error.code, "EBADORIGIN"); + }); + + it("allows requests with no Origin (CLI/curl)", async () => { + tmux.__setExecImpl((args) => { + if (args[0] === "list-sessions") { + const e = new Error("no server running"); + e.status = 1; + throw e; + } + return ""; + }); + const { status, body } = await fetchJson("/api/run"); + assert.equal(status, 200); + assert.deepEqual(body.items, []); + }); + + it("POST / rejects a missing laneId", async () => { + const { status, body } = await fetchJson("/api/run", { method: "POST", body: { cwd: TMP } }); + assert.equal(status, 400); + assert.equal(body.error.code, "EBADLANE"); + }); + + it("POST / rejects a non-existent cwd", async () => { + const { status, body } = await fetchJson("/api/run", { + method: "POST", + body: { laneId: 1, cwd: "/definitely/not/a/real/path" }, + }); + assert.equal(status, 400); + assert.equal(body.error.code, "EBADCWD"); + }); + + it("POST / spawns a tmux session and GET /:id finds it", async () => { + let hasSessionCalls = 0; + tmux.__setExecImpl((args) => { + if (args[0] === "has-session") { + hasSessionCalls++; + // First call (inside spawnRun): not yet running. Every call after + // (GET /:id) sees it as running. + if (hasSessionCalls === 1) { + const e = new Error("gone"); + e.status = 1; + throw e; + } + return ""; + } + return ""; + }); + const spawned = await fetchJson("/api/run", { method: "POST", body: { laneId: 9, cwd: TMP } }); + assert.equal(spawned.status, 201); + assert.equal(spawned.body.id, "ccam-lane-9"); + + const fetched = await fetchJson(`/api/run/${spawned.body.id}`); + assert.equal(fetched.status, 200); + assert.equal(fetched.body.status, "running"); + }); + + it("DELETE /:id kills a live tmux session", async () => { + tmux.__setExecImpl(() => ""); // has-session succeeds; kill-session succeeds + const { status, body } = await fetchJson("/api/run/ccam-lane-9", { method: "DELETE" }); + assert.equal(status, 200); + assert.deepEqual(body, { ok: true }); + }); + + it("DELETE /:id returns 404 for a session that doesn't exist", async () => { + tmux.__setExecImpl((args) => { + if (args[0] === "has-session") { + const e = new Error("gone"); + e.status = 1; + throw e; + } + return ""; + }); + const { status } = await fetchJson("/api/run/ccam-lane-999", { method: "DELETE" }); + assert.equal(status, 404); + }); + + it("GET /tmux reports availability from the tmux wrapper", async () => { + tmux.__setExecImpl(() => "tmux 3.4"); + const { body } = await fetchJson("/api/run/tmux"); + assert.equal(body.available, true); + }); + + it("GET /cwds still returns suggested directories (unchanged behavior)", async () => { + const { status, body } = await fetchJson("/api/run/cwds"); + assert.equal(status, 200); + assert.ok(Array.isArray(body.items)); + }); +}); +``` + +- [ ] **Step 7: Run the full server suite** + +```bash +node --test server/__tests__/*.test.js 2>&1 | tail -40 +``` + +Expected: PASS. If `GET /api/run/tmux` 404s, check Express route ordering — `router.get("/tmux", ...)` must be registered before any `router.get("/:id", ...)` for the same router, otherwise Express matches `:id = "tmux"` first (same class of bug this repo's own docs warn about for `/lanes/gc` vs `/lanes/:id`). + +- [ ] **Step 8: Commit** + +```bash +git add server/routes/run.js server/__tests__/run.test.js +git commit -m "feat(run): rewrite routes for the tmux backend, drop stdin-message endpoint" +``` + +--- + +### Task 5: `/ws-pty/:runId` — PTY WebSocket transport + +**Files:** +- Create: `server/lib/pty-attach.js` (the `node-pty` attach logic, separated from `websocket.js` so it stays testable without a real socket) +- Modify: `server/websocket.js` (add the second `WebSocketServer`) +- Test: `server/__tests__/pty-attach.test.js` + +**Interfaces:** +- Consumes: `pty-run.js`'s `laneIdFromRunId` (Task 3, for validating `runId` before touching tmux); `node-pty`'s `spawn`. +- Produces: `initPtyWebSocket(server)` (called from `server/index.js` next to the existing `initWebSocket(server)`), exported from `server/websocket.js`. + +- [ ] **Step 1: Write the failing test for the attach helper** + +```javascript +// server/__tests__/pty-attach.test.js +/** + * @file pty-attach.test.js + * @description Unit tests for the PTY-attach helper's runId validation and + * data/control framing, using a fake node-pty implementation (no real tmux + * or PTY spawned). + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const ptyAttach = require("../lib/pty-attach"); + +function makeFakePty() { + const emitter = new EventEmitter(); + const writes = []; + const pty = { + onData: (fn) => emitter.on("data", fn), + onExit: (fn) => emitter.on("exit", fn), + write: (d) => writes.push(d), + resize: (cols, rows) => writes.push({ resize: [cols, rows] }), + kill: () => emitter.emit("exit", { exitCode: 0 }), + __emitter: emitter, + __writes: writes, + }; + return pty; +} + +function makeFakeWs() { + const emitter = new EventEmitter(); + const sent = []; + return { + on: (evt, fn) => emitter.on(evt, fn), + send: (data, opts) => sent.push({ data, binary: !!(opts && opts.binary) }), + __emitter: emitter, + __sent: sent, + }; +} + +describe("pty-attach", () => { + let fakePty; + beforeEach(() => { + fakePty = makeFakePty(); + ptyAttach.__setSpawnImpl(() => fakePty); + }); + + it("rejects a runId that doesn't match ccam-lane-", () => { + assert.throws(() => ptyAttach.validateRunId("../../etc/passwd"), /EBADRUNID/); + assert.throws(() => ptyAttach.validateRunId("some-other-session"), /EBADRUNID/); + }); + + it("accepts a well-formed runId", () => { + assert.doesNotThrow(() => ptyAttach.validateRunId("ccam-lane-42")); + }); + + it("wires PTY data to binary WS frames and WS binary frames to PTY writes", () => { + const ws = makeFakeWs(); + ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 }); + + fakePty.__emitter.emit("data", "hello from claude"); + assert.equal(ws.__sent.length, 1); + assert.equal(ws.__sent[0].data, "hello from claude"); + assert.equal(ws.__sent[0].binary, true); + + ws.__emitter.emit("message", Buffer.from("typed text"), { binary: true }); + assert.deepEqual(fakePty.__writes[0], "typed text"); + }); + + it("routes a JSON text frame with type resize to pty.resize", () => { + const ws = makeFakeWs(); + ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 }); + ws.__emitter.emit("message", Buffer.from(JSON.stringify({ type: "resize", cols: 100, rows: 40 })), { + binary: false, + }); + assert.deepEqual(fakePty.__writes[0], { resize: [100, 40] }); + }); + + it("sends an exit control message and closes on PTY exit", () => { + const ws = makeFakeWs(); + let closed = false; + ws.close = () => { + closed = true; + }; + ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 }); + fakePty.__emitter.emit("exit", { exitCode: 0 }); + const last = ws.__sent[ws.__sent.length - 1]; + assert.equal(last.binary, false); + assert.deepEqual(JSON.parse(last.data), { type: "exit", code: 0 }); + assert.equal(closed, true); + }); + + it("kills the PTY attach process when the WS connection closes", () => { + const ws = makeFakeWs(); + ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 }); + let killed = false; + fakePty.kill = () => { + killed = true; + }; + ws.__emitter.emit("close"); + assert.equal(killed, true); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +node --test server/__tests__/pty-attach.test.js +``` + +Expected: FAIL — `Cannot find module '../lib/pty-attach'`. + +- [ ] **Step 3: Implement `server/lib/pty-attach.js`** + +```javascript +/** + * @file pty-attach.js + * @description Bridges one WebSocket connection to a `node-pty`-backed + * `tmux attach-session` process. Binary WS frames carry raw PTY bytes in + * both directions; text WS frames carry small JSON control messages + * (`resize`, and an outbound `exit` sent once when the pane process/tmux + * session ends). Multiple browser tabs each get their own PTY attach + * process — tmux itself is what keeps them all in sync, this module does no + * cross-connection coordination. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const RUN_ID_RE = /^ccam-lane-\d+$/; + +/** + * Reject anything that isn't exactly `ccam-lane-` before it can ever + * reach a tmux/PTY command — the trust boundary for this WS path, since a + * validated runId is the only thing standing between an authenticated WS + * client and naming an arbitrary session on the host. + */ +function validateRunId(runId) { + if (typeof runId !== "string" || !RUN_ID_RE.test(runId)) { + const err = new Error(`invalid runId: ${runId}`); + err.code = "EBADRUNID"; + throw err; + } +} + +// Test seam — real implementation set in Step 4. +let spawnImpl = null; +function __setSpawnImpl(fn) { + spawnImpl = fn; +} + +/** + * Attach `ws` to the tmux session `runId`. Spawns one PTY-backed + * `tmux attach-session -t ` per call. + */ +function attach(ws, runId, { cols, rows }) { + validateRunId(runId); + const pty = spawnImpl("tmux", ["attach-session", "-t", runId], { + name: "xterm-256color", + cols: cols || 80, + rows: rows || 24, + }); + + pty.onData((data) => { + try { + ws.send(data, { binary: true }); + } catch { + /* client gone between data event and send — safe to ignore */ + } + }); + + pty.onExit(({ exitCode }) => { + try { + ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false }); + } catch { + /* ignore */ + } + try { + ws.close(); + } catch { + /* ignore */ + } + }); + + ws.on("message", (data, isBinary) => { + // node-pty attach processes for `tmux attach` are already tolerant of + // resize mid-stream; the `ws` lib passes isBinary either as the second + // callback arg (newer) or via `data.binary` on some transports — this + // helper's own tests exercise the `{binary}` option shape used above. + const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary); + if (binary) { + pty.write(data.toString("utf8")); + return; + } + let msg; + try { + msg = JSON.parse(data.toString("utf8")); + } catch { + return; + } + if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) { + pty.resize(msg.cols, msg.rows); + } + }); + + ws.on("close", () => { + try { + pty.kill(); + } catch { + /* already gone */ + } + }); + + return pty; +} + +// Real spawn implementation — lazy-required so unit tests never load the +// native node-pty addon unless they explicitly opt in. +__setSpawnImpl((...args) => require("node-pty").spawn(...args)); + +module.exports = { attach, validateRunId, __setSpawnImpl }; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +node --test server/__tests__/pty-attach.test.js +``` + +Expected: PASS, all 6 tests. + +- [ ] **Step 5: Wire the second WebSocket server in `server/websocket.js`** + +Add below the existing `initWebSocket` function (after its closing brace, before `function broadcast`): + +```javascript +let ptyWss = null; +const PTY_PATH_RE = /^\/ws-pty\/(ccam-lane-\d+)$/; + +/** + * Second WebSocket server, dedicated to the terminal-run PTY transport + * (`/ws-pty/:runId`). Kept separate from the `/ws` JSON-broadcast path so + * raw binary PTY frames never have to coexist with the typed + * `{type, data, timestamp}` envelope the rest of the app relies on. Reuses + * the exact same auth guard as `/ws`. + * + * `runId` is a path SEGMENT, not a fixed string, so this can't use the `ws` + * library's `{server, path}` shorthand (that option only matches an exact + * string). Instead this server is created with `noServer: true` and the + * upgrade is handled manually — the same `server.on("upgrade", ...)` pattern + * `ws` itself uses internally, just filtered to `/ws-pty/*` first so `/ws`'s + * own upgrade handling (already registered by `initWebSocket`) is untouched. + */ +function initPtyWebSocket(server) { + const { attach } = require("./lib/pty-attach"); + ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); + + ptyWss.on("connection", (ws, runId) => { + try { + attach(ws, runId, { cols: 80, rows: 24 }); + } catch (err) { + try { + ws.close(1008, err.message); + } catch { + /* ignore */ + } + } + }); + + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url, "http://localhost"); + const match = url.pathname.match(PTY_PATH_RE); + if (!match) return; // not ours — the `/ws` WebSocketServer (registered + // by initWebSocket, also attached to this same http.Server) handles its + // own path independently and ignores upgrades it doesn't match too. + if (!isHostAllowed(req.headers.host)) { + socket.destroy(); + return; + } + if (!isWebSocketAuthorized(req)) { + socket.destroy(); + return; + } + ptyWss.handleUpgrade(req, socket, head, (ws) => { + ptyWss.emit("connection", ws, match[1]); + }); + }); + + return ptyWss; +} +``` + +Update the file's `module.exports` (last line) to include the new function, and update `closeWebSocket` to also terminate `ptyWss` clients: + +```javascript +function closeWebSocket() { + if (wss) { + wss.clients.forEach((client) => { + try { + client.terminate(); + } catch { + /* already gone */ + } + }); + try { + wss.close(); + } catch { + /* ignore */ + } + wss = null; + } + if (ptyWss) { + ptyWss.clients.forEach((client) => { + try { + client.terminate(); + } catch { + /* already gone */ + } + }); + try { + ptyWss.close(); + } catch { + /* ignore */ + } + ptyWss = null; + } +} + +module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket }; +``` + +(Delete the old standalone `closeWebSocket` definition — this replaces it, don't leave two.) + +- [ ] **Step 6: Wire `initPtyWebSocket` in `server/index.js`** + +Find `initWebSocket(server);` (line 152) and add directly below it: + +```javascript + initWebSocket(server); + require("./websocket").initPtyWebSocket(server); +``` + +- [ ] **Step 7: Manual smoke check (real tmux, real node-pty — not part of the automated suite)** + +```bash +npm start & +sleep 1 +tmux new-session -d -s ccam-lane-999 -c /tmp -- sleep 60 +node -e " +const WebSocket = require('ws'); +const ws = new WebSocket('ws://127.0.0.1:4820/ws-pty/ccam-lane-999'); +ws.on('open', () => console.log('connected')); +ws.on('message', (d) => console.log('got', d.length, 'bytes')); +ws.on('close', (code) => console.log('closed', code)); +setTimeout(() => process.exit(0), 3000); +" +tmux kill-session -t ccam-lane-999 +kill %1 +``` + +Expected: `connected` printed, no error, process exits cleanly at the 3s timeout. + +- [ ] **Step 8: Commit** + +```bash +git add server/lib/pty-attach.js server/__tests__/pty-attach.test.js server/websocket.js server/index.js +git commit -m "feat(run): add /ws-pty/:runId PTY transport bridging WS to tmux attach" +``` + +--- + +### Task 6: `TerminalView.tsx` — the xterm.js client component + +**Files:** +- Create: `client/src/components/run/TerminalView.tsx` +- Test: `client/src/components/run/__tests__/TerminalView.test.tsx` + +**Interfaces:** +- Consumes: `@xterm/xterm`'s `Terminal`, `@xterm/addon-fit`'s `FitAddon`. +- Produces: `` — a self-contained component; Task 9's Workspace wiring is the only caller. + +- [ ] **Step 1: Write the failing test** + +```typescript +// client/src/components/run/__tests__/TerminalView.test.tsx +/** + * @file TerminalView.test.tsx + * @description Tests for the xterm.js-backed terminal view: verifies it opens + * a WS connection to the right URL, writes incoming binary frames to the + * mocked terminal, and forwards typed input as outgoing binary frames. + * @author Nguyễn Ngọc Trí Vĩ + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { TerminalView } from "../TerminalView"; + +const writeMock = vi.fn(); +const onDataHandlers: Array<(d: string) => void> = []; +const openMock = vi.fn(); +const disposeMock = vi.fn(); + +vi.mock("@xterm/xterm", () => ({ + Terminal: vi.fn().mockImplementation(() => ({ + open: openMock, + write: writeMock, + onData: (fn: (d: string) => void) => onDataHandlers.push(fn), + dispose: disposeMock, + loadAddon: vi.fn(), + })), +})); +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), +})); + +class MockWebSocket { + static instances: MockWebSocket[] = []; + url: string; + sent: unknown[] = []; + onopen: (() => void) | null = null; + onmessage: ((e: { data: unknown }) => void) | null = null; + onclose: (() => void) | null = null; + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + send(data: unknown) { + this.sent.push(data); + } + close() { + this.onclose?.(); + } +} +// @ts-expect-error test override +global.WebSocket = MockWebSocket; + +describe("TerminalView", () => { + beforeEach(() => { + MockWebSocket.instances = []; + onDataHandlers.length = 0; + writeMock.mockClear(); + openMock.mockClear(); + }); + + afterEach(cleanup); + + it("opens a WS connection to the run's ws-pty path", () => { + render(); + expect(MockWebSocket.instances).toHaveLength(1); + expect(MockWebSocket.instances[0].url).toBe("ws://localhost:4820/ws-pty/ccam-lane-1"); + }); + + it("writes incoming WS data to the terminal", () => { + render(); + const ws = MockWebSocket.instances[0]; + ws.onopen?.(); + ws.onmessage?.({ data: "hello" }); + expect(writeMock).toHaveBeenCalledWith("hello"); + }); + + it("forwards terminal keystrokes as outgoing WS sends", () => { + render(); + const ws = MockWebSocket.instances[0]; + onDataHandlers[0]("ls -la\r"); + expect(ws.sent).toEqual(["ls -la\r"]); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd client && npx vitest run src/components/run/__tests__/TerminalView.test.tsx +``` + +Expected: FAIL — `Failed to resolve import "../TerminalView"`. + +- [ ] **Step 3: Implement `TerminalView.tsx`** + +```typescript +/** + * @file TerminalView.tsx + * @description Renders one lane's live terminal — a real `xterm.js` instance + * attached via WebSocket to the server's `/ws-pty/:runId` path (see + * server/lib/pty-attach.js), which is itself a `node-pty`-backed + * `tmux attach-session`. Binary WS frames are raw PTY bytes in both + * directions; a JSON text frame carries the initial `resize` on mount and + * the server's one-shot `exit` notice when the pane process ends. + * @author Nguyễn Ngọc Trí Vĩ + */ +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; + +interface TerminalViewProps { + runId: string; + wsBaseUrl: string; +} + +export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { + const containerRef = useRef(null); + + useEffect(() => { + const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true }); + const fit = new FitAddon(); + term.loadAddon(fit); + if (containerRef.current) term.open(containerRef.current); + fit.fit(); + + const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`); + + ws.onopen = () => { + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + }; + ws.onmessage = (event) => { + if (typeof event.data === "string") { + // Binary PTY output arrives as text here too (the browser WS API + // decodes non-Blob/ArrayBuffer frames as strings) — a JSON control + // frame is the only thing that starts with `{"type"`. + if (event.data.startsWith('{"type"')) { + try { + const msg = JSON.parse(event.data); + if (msg.type === "exit") { + term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`); + } + return; + } catch { + /* not JSON — fall through and render as PTY output */ + } + } + term.write(event.data); + } + }; + + const dataDisposable = term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) ws.send(data); + }); + + const resizeObserver = new ResizeObserver(() => { + fit.fit(); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + } + }); + if (containerRef.current) resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + dataDisposable.dispose(); + ws.close(); + term.dispose(); + }; + }, [runId, wsBaseUrl]); + + return
; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd client && npx vitest run src/components/run/__tests__/TerminalView.test.tsx +``` + +Expected: PASS, all 3 tests. If `ResizeObserver is not defined` appears, add to `client/vitest.config.ts`'s existing test setup file a `global.ResizeObserver = class { observe() {} disconnect() {} };` stub (check `client/src/test/setup.ts` or equivalent referenced by `vitest.config.ts`'s `setupFiles` first — this repo already has one for other DOM APIs jsdom lacks). + +- [ ] **Step 5: Commit** + +```bash +git add client/src/components/run/TerminalView.tsx client/src/components/run/__tests__/TerminalView.test.tsx +git commit -m "feat(run): add TerminalView xterm.js component for the PTY transport" +``` + +--- + +### Task 7: `client/src/lib/api.ts` and `types.ts` — new run types and endpoints + +**Files:** +- Modify: `client/src/lib/api.ts:2510-2621` (types), `:1476-1503` (the `run` object) +- Modify: `client/src/lib/types.ts:1279-1327` (delete `RunStreamPayload`, `RunInputAckPayload`; keep/trim `RunStatusPayload` — still useful for the run-history list's live/dead badge, no envelope-specific fields needed) + +**Interfaces:** +- Produces: `api.run.list()`, `api.run.start(args)`, `api.run.kill(id)`, `api.run.tmuxAvailable()`, `RunHandle`, `RunStartArgs` — the shapes Task 8's `RunSetup`/`RunHistory` edits and Task 9's `Workspace` wiring consume. + +- [ ] **Step 1: Replace the type block in `api.ts`** + +Replace the entire block from `export type RunMode = ...` (line 2510) through the end of `RunListResponse` (line 2581) with: + +```typescript +/** Maps 1:1 to the `claude --permission-mode` CLI flag. */ +export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions"; +/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */ +export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max"; +/** "running" — a live tmux session exists; "gone" — it doesn't (killed, + * crashed, claude exited and closed the pane). Computed fresh from tmux + * state on every read, never cached. */ +export type RunStatus = "running" | "gone"; + +/** Body for POST /api/run - parameters for starting a lane's terminal run. */ +export interface RunStartArgs { + laneId: number; + cwd?: string; + model?: string; + permissionMode?: PermissionMode; + /** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */ + resumeSessionId?: string; + effort?: EffortLevel; + /** Sent as `claude`'s first positional message once the pane boots; omit + * to just open the pane and let the user type. */ + initialPrompt?: string; +} + +/** A lane's tmux-backed terminal run — one per lane, id is the tmux session + * name (`ccam-lane-`). */ +export interface RunHandle { + id: string; + laneId: number | null; + status: RunStatus; + cwd: string | null; + model: string | null; + permissionMode: PermissionMode | null; + effort: EffortLevel | null; + resumeSessionId: string | null; + /** Claude Code session id this run created/resumed, once known. */ + sessionId: string | null; + /** ISO timestamp the tmux session was created. */ + startedAt: string | null; +} + +/** Response shape of GET /api/run. */ +export interface RunListResponse { + items: RunHandle[]; +} +``` + +Leave `DashboardRunHistoryItem` (line 2592-2612) and `CwdSuggestion` (line 2615-2621) as-is except drop the now-removed `mode`/`RunMode` field from `DashboardRunHistoryItem`: + +```typescript +export interface DashboardRunHistoryItem { + id: string; + session_id: string | null; + cwd: string; + model: string | null; + permission_mode: PermissionMode | null; + effort: EffortLevel | null; + resume_session_id: string | null; + prompt_preview: string | null; + status: "running" | "killed" | "abandoned"; + exit_code: number | null; + started_at: string; + ended_at: string | null; + isLive: boolean; +} +``` + +- [ ] **Step 2: Replace the `api.run` object** + +Replace the whole block from `run: {` (line 1476) through its closing `},` (before the next top-level key) with: + +```typescript + run: { + /** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */ + list: () => request("/run"), + /** GET /api/run/history - persisted run history from `dashboard_runs`. */ + history: (limit = 50, options?: { laneId?: number }) => { + const qs = new URLSearchParams({ limit: String(limit) }); + if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId)); + return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`); + }, + /** GET /api/run/binary - whether `claude` was found on PATH. */ + binary: () => request<{ found: boolean; path: string | null }>("/run/binary"), + /** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */ + tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"), + /** GET /api/run/cwds - suggested working directories for the cwd picker. */ + cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"), + /** GET /api/run/files - path-completion suggestions under `cwd`. */ + files: (cwd: string, q?: string) => { + const qs = new URLSearchParams({ cwd }); + if (q) qs.set("q", q); + return request<{ items: string[] }>(`/run/files?${qs.toString()}`); + }, + /** POST /api/run - start (or adopt, if already live) a lane's terminal run. */ + start: (args: RunStartArgs) => request("/run", { method: "POST", body: JSON.stringify(args) }), + /** GET /api/run/:id - one run's current handle. */ + get: (id: string) => request(`/run/${encodeURIComponent(id)}`), + /** DELETE /api/run/:id - kill the tmux session. */ + kill: (id: string) => request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }), + }, +``` + +- [ ] **Step 3: Trim `types.ts`'s run-streaming section** + +Replace the block from `// ───── Interactive run streaming ─────` (line 1279) through the end of `RunInputAckPayload` (line 1327) with: + +```typescript +// ───── Terminal run status ───── +// A lane's terminal run is a tmux session; its own live/dead state is polled +// via GET /api/run, not pushed over the WS envelope path. This payload only +// covers the one thing worth pushing live: a run ending (killed or the pane +// process exiting) so an open Workspace tab can update its badge/switcher +// without polling. + +/** Payload for the `run_status` WebSocket message. */ +export interface RunStatusPayload { + /** The run id (tmux session name, `ccam-lane-`). */ + id: string; + status: "running" | "gone"; + /** Epoch-ms timestamp of this transition. */ + at: number; +} +``` + +- [ ] **Step 4: Type-check the client** + +```bash +cd client && npx tsc --noEmit 2>&1 | head -60 +``` + +Expected: errors ONLY in files this plan hasn't touched yet (`RunConsole.tsx`, `RunSetup.tsx`, `RunHistory.tsx`, `Workspace.tsx`, `useRunStream.ts`) — those are fixed in Tasks 8-10. If you see errors in `api.ts`/`types.ts` themselves, fix them before proceeding. + +- [ ] **Step 5: Commit** + +```bash +git add client/src/lib/api.ts client/src/lib/types.ts +git commit -m "feat(run): replace RunHandle/RunStartArgs types and api.run for the tmux backend" +``` + +--- + +### Task 8: Delete the stream-json client code and server pieces it made obsolete + +**Files:** +- Delete: `client/src/components/run/RunConsole.tsx` +- Delete: `client/src/hooks/useRunStream.ts`, `client/src/hooks/__tests__/useRunStream.test.tsx` +- Delete: `server/lib/run-spawner.js`, `server/__tests__/run.test.js`'s old fake-child helper (already replaced in Task 4 Step 6 — this step is the actual `rm`, confirming Task 4 already left nothing pointing at the old file) +- Modify: `server/lib/stream-json-parser.js` — delete ONLY if Step 1's grep confirms no other caller + +**Interfaces:** +- Consumes: nothing new. +- Produces: a clean tree with no dangling imports of the deleted files (verified in Step 3). + +- [ ] **Step 1: Confirm `stream-json-parser.js` has no caller outside the deleted spawner** + +```bash +grep -rln "stream-json-parser" --include=*.js --include=*.ts server/ | grep -v node_modules +``` + +Expected: only `server/lib/run-spawner.js` (about to be deleted). If anything else shows up, stop and leave `stream-json-parser.js` in place — do not delete it. + +- [ ] **Step 2: Delete the files** + +```bash +git rm client/src/components/run/RunConsole.tsx +git rm client/src/hooks/useRunStream.ts client/src/hooks/__tests__/useRunStream.test.tsx +git rm server/lib/run-spawner.js +git rm server/lib/stream-json-parser.js # only if Step 1's grep was clean +find server/__tests__ -iname "*stream-json-parser*" -exec git rm {} \; # if such a test file exists +``` + +- [ ] **Step 3: Grep for dangling references** + +```bash +grep -rln "run-spawner\|useRunStream\|RunConsole\|stream-json-parser" --include=*.js --include=*.ts --include=*.tsx client/src server | grep -v node_modules +``` + +Expected: empty, OR only `client/src/components/run/RunSetup.tsx` and `client/src/components/run/RunHistory.tsx` and `client/src/pages/Workspace.tsx` — those three are fixed in Tasks 9-10, not this task. + +- [ ] **Step 4: Run both suites to see the expected remaining breakage** + +```bash +node --test server/__tests__/*.test.js 2>&1 | tail -20 +cd client && npx vitest run 2>&1 | tail -40 +``` + +Expected: server suite PASSES (nothing else referenced `run-spawner.js`/`stream-json-parser.js`). Client suite FAILS only on files Tasks 9-10 haven't touched yet (`RunSetup.test.tsx`, `RunHistory`-related tests, `Workspace.test.tsx`, `screens.snapshot.test.tsx`) — confirm the failures are import errors pointing at `RunConsole`/`useRunStream`, not something unrelated. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore(run): delete stream-json spawner, parser, and chat-bubble console" +``` + +--- + +### Task 9: `RunSetup.tsx` and `RunHistory.tsx` — adjust to the new data shape + +**Files:** +- Modify: `client/src/components/run/RunSetup.tsx` +- Modify: `client/src/components/run/RunHistory.tsx` +- Test: existing test files for both (adjust, don't rewrite from scratch — the picker/switcher UI itself is unchanged, only the data it submits/reads) + +**Interfaces:** +- Consumes: `RunStartArgs`, `RunHandle`, `DashboardRunHistoryItem` (Task 7). +- Produces: `RunSetup`'s `onSubmit` now calls the caller with a `RunStartArgs`-shaped object (no more `mode`); `RunHistory`'s `ActiveRunsSwitcher`/`RunsModal` render `RunHandle`/`DashboardRunHistoryItem` without a mode badge. + +- [ ] **Step 1: Read `RunSetup.tsx`'s current mode-toggle UI and submit handler** + +```bash +grep -n "mode\b" client/src/components/run/RunSetup.tsx | head -40 +``` + +This surfaces every line touching the headless/conversation toggle — the exact lines to remove depend on what that grep returns (this file wasn't fully quoted in the spec's research pass). Locate: +1. The `mode` field in `RunSetupProps`/local state (`useState<"headless" | "conversation">`). +2. The toggle UI (likely a `` pair — this file's own `Seg` helper component, used elsewhere for the permission-mode/effort pickers). +3. Wherever the submit handler builds the `RunStartArgs` object. + +- [ ] **Step 2: Remove the mode toggle and update the submit shape** + +Delete the mode `useState` and its `` toggle UI block entirely. In the submit handler, replace whatever currently builds `{prompt, mode, cwd, model, ...}` with: + +```typescript +onSubmit({ + laneId, + cwd, + model: model || undefined, + permissionMode, + effort: effort || undefined, + resumeSessionId: resumeSessionId || undefined, + initialPrompt: prompt || undefined, +}); +``` + +(`laneId` must already be available to this component from its props — check `RunSetupProps`; if it currently only receives `cwd`, add `laneId: number` to `RunSetupProps` and thread it from `Workspace.tsx` in Task 10, since every run is now lane-scoped by construction.) + +The prompt textarea's label/placeholder text should change from something like "First message" (framed as the one-shot headless prompt) to "Send once the terminal opens (optional)" — grep the file for its current placeholder string and update the copy to match the new semantics; keep the JSX structure otherwise unchanged. + +- [ ] **Step 3: Remove `ModeBadge` and `mode`-based rendering in `RunHistory.tsx`** + +```bash +grep -n "\bmode\b\|ModeBadge\|RunMode" client/src/components/run/RunHistory.tsx +``` + +`ModeBadge` itself lives in the just-deleted `RunConsole.tsx` (Task 8) — every import/usage of it in `RunHistory.tsx` must be removed. Each history row currently showing a mode badge should instead show nothing in that slot (the row's other badges — status, resume-vs-fresh — are unaffected). + +- [ ] **Step 4: Update `UnifiedRunRow` / kill / resume actions to call the new API shape** + +`RunHistory.tsx`'s kill button already calls `api.run.kill(id)` (Task 7 kept that signature identical — no change needed there). Its resume action (if it directly calls `api.run.start`) must drop `mode`/`prompt` in favor of `resumeSessionId`/`initialPrompt` per the new `RunStartArgs`. + +- [ ] **Step 5: Fix the adjusted tests** + +```bash +find client/src/components/run/__tests__ -iname "*RunSetup*" -o -iname "*RunHistory*" +``` + +For each matching test file: remove any test asserting the mode toggle's presence/behavior; remove any `mode:` field from mocked `RunStartArgs`/`RunHandle`/`DashboardRunHistoryItem` fixtures (TypeScript will already flag these via Step 6's type-check — fix compile errors first, then re-run). + +- [ ] **Step 6: Type-check and run the client suite** + +```bash +cd client && npx tsc --noEmit 2>&1 | head -60 +npx vitest run src/components/run 2>&1 | tail -60 +``` + +Expected: no errors in `RunSetup.tsx`/`RunHistory.tsx` or their tests. Remaining errors, if any, are confined to `Workspace.tsx` (Task 10). + +- [ ] **Step 7: Commit** + +```bash +git add client/src/components/run/RunSetup.tsx client/src/components/run/RunHistory.tsx client/src/components/run/__tests__ +git commit -m "feat(run): adjust RunSetup/RunHistory to the tmux RunStartArgs/RunHandle shape" +``` + +--- + +### Task 10: `Workspace.tsx` — swap `RunConsole`/`useRunStream` for `TerminalView` + +**Files:** +- Modify: `client/src/pages/Workspace.tsx` +- Modify: `client/src/pages/__tests__/Workspace.test.tsx` + +**Interfaces:** +- Consumes: `TerminalView` (Task 6), the new `api.run`/`RunHandle`/`RunStartArgs` (Task 7), `RunSetup`/`ActiveRunsSwitcher` (Task 9). + +- [ ] **Step 1: Locate every reference to the deleted pieces** + +```bash +grep -n "RunConsole\|useRunStream\|envelopes\b" client/src/pages/Workspace.tsx +``` + +- [ ] **Step 2: Replace the import** + +```typescript +// before +import { RunConsole } from "../components/run/RunConsole"; +import { useRunStream } from "../hooks/useRunStream"; +// after +import { TerminalView } from "../components/run/TerminalView"; +``` + +- [ ] **Step 3: Remove the `useRunStream` call and its derived state** + +Delete the `useRunStream(...)` call (around line 65 per the earlier research) and its `envelopes`/`displayEnvelopes`/`onStatus`/`onInputAck`/`onAnyStatus` plumbing. The `selectedRunId`/`runHandle` state that already exists stays — it's still how the page tracks which lane's run is open; only the envelope-streaming half goes away. + +- [ ] **Step 4: Replace the `` render with ``** + +```typescript +// before (shape approximate — match whatever props RunConsole actually received) + api.run.send(runHandle.id, text)} + onKill={() => api.run.kill(runHandle.id)} +/> +// after +{runHandle && ( + +)} +``` + +Where `wsBaseUrl` is derived the same way this file already derives its existing `/ws` connection's base URL (grep `new WebSocket(` or similar in this file/`eventBus.ts` for the existing pattern and reuse it — likely `window.location.origin.replace(/^http/, "ws")` or an env-driven constant; match whatever's already there rather than introducing a second convention). + +- [ ] **Step 5: Update the Start/Resume handlers to the new `RunStartArgs` shape** + +Wherever `Workspace.tsx` calls `api.run.start(...)` (around lines 205-761 per the earlier research), update the payload to the Task 7 shape: + +```typescript +const handle = await api.run.start({ + laneId: currentLane.id, + cwd: currentLane.cwd, + model: selectedModel || undefined, + permissionMode: selectedPermissionMode, + effort: selectedEffort || undefined, + resumeSessionId: resumeTarget?.session_id, + initialPrompt: promptText || undefined, +}); +setRunHandle(handle); +``` + +Remove any `mode: "headless" | "conversation"` selection state this page owned for the run-setup flow if `RunSetup.tsx` no longer emits it (Task 9 already removed it from `RunSetup`'s output — this page must stop reading a `mode` field from that callback's argument object). + +- [ ] **Step 6: Update `Workspace.test.tsx`'s run-related mocks** + +```bash +grep -n "api.run\.\|RunConsole\|useRunStream\|mode:" client/src/pages/__tests__/Workspace.test.tsx +``` + +Update the mocked `api.run.start`/`api.run.list`/`api.run.kill` implementations to return the new `RunHandle` shape (no `mode`, `argv`, `envelopeCount`, etc. — see Task 7's type). Remove any assertion that inspects `RunConsole`-rendered chat bubbles; replace with an assertion that `TerminalView`'s `data-testid="terminal-view"` container renders once a run is active (mock `TerminalView` itself with `vi.mock("../../components/run/TerminalView", ...)` returning a stub `
`, the same way this repo already stubs heavy child components in page-level tests — grep the file for an existing `vi.mock("../../components/...")` to match the pattern). + +- [ ] **Step 7: Type-check, run the client suite, regenerate the snapshot** + +```bash +cd client && npx tsc --noEmit 2>&1 | head -60 +npx vitest run 2>&1 | tail -80 +``` + +Once everything but the snapshot passes: + +```bash +cd client && npx vitest run -u +git diff client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +``` + +Review the diff — expect changes ONLY in the Workspace screen's run panel (mode badge gone, terminal container instead of chat bubbles). Anything else changing is a regression — investigate before accepting. + +- [ ] **Step 8: Commit** + +```bash +git add client/src/pages/Workspace.tsx client/src/pages/__tests__/Workspace.test.tsx client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +git commit -m "feat(run): wire Workspace to TerminalView, drop stream-json envelope plumbing" +``` + +--- + +### Task 11: `ccam lanes shell` CLI command + +**Files:** +- Modify: `bin/ccam.js` (dispatch table + new `cmdLanesShell` function) + +**Interfaces:** +- Consumes: `resolveLaneArg` (existing helper, same one `cmdLanesPipeline`/`cmdStage` use), `get`/`patch` HTTP helpers (existing), `node:child_process`'s `spawn` (already imported at the top of this file per the earlier research). + +- [ ] **Step 1: Add the dispatch entry** + +Find the `case "lanes":` block's if-chain (around line 3333-3368 per the earlier research) and add, alongside the other `rest[0]` checks (order doesn't matter among siblings, but keep it near `pipeline`/`gc` for readability): + +```javascript +if (rest[0] === "shell") return cmdLanesShell(rest.slice(1)); +``` + +- [ ] **Step 2: Implement `cmdLanesShell`** + +Add this function near `cmdLanesPipeline` (same file, same section): + +```javascript +/** + * Attach a real terminal to the exact tmux session the dashboard uses for + * this lane's run. Creates it if it doesn't exist yet (`tmux new-session -A` + * is create-or-attach, the same idempotent semantics as clicking Start on + * the dashboard). The user types `claude` themselves inside — this command's + * only job is landing them in the right named session. + */ +async function cmdLanesShell(args) { + const resolved = await resolveLaneArg(args); + if (!resolved) return; + const { lane } = await get(`/api/lanes/${resolved.laneId}`); + const sessionName = `ccam-lane-${lane.id}`; + const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], { + stdio: "inherit", + }); + await new Promise((resolve) => { + child.on("close", resolve); + child.on("error", (err) => { + console.error(c.red(`✖ ${err?.message || err}`)); + process.exitCode = 1; + resolve(); + }); + }); +} +``` + +- [ ] **Step 3: Manual verification (interactive — not part of the automated suite)** + +```bash +cd /path/to/some/adopted/lane +node /path/to/ccam-lanes/bin/ccam.js lanes shell +``` + +Expected: drops into a tmux session named `ccam-lane-`; typing `claude` starts a real interactive session; `Ctrl-B D` detaches without killing it; running the same command again re-attaches to the same session instead of creating a new one. + +- [ ] **Step 4: Update `ccam --help` and `docs/LANES.md`'s command reference** + +```bash +grep -n '"help"' bin/ccam.js | head -5 +``` + +Add a one-line entry for `ccam lanes shell` next to the existing `ccam lanes pipeline` entry in whatever help-text block that grep surfaces (match the existing entries' format exactly). + +- [ ] **Step 5: Commit** + +```bash +git add bin/ccam.js +git commit -m "feat(cli): add 'ccam lanes shell' to attach a real terminal to a lane's tmux session" +``` + +--- + +### Task 12: Docs — README, ARCHITECTURE, docs/API.md, docs/LANES.md + +**Files:** +- Modify: `README.md` (the "Run Claude from the browser" bullet under "What it does") +- Modify: `README.vi.md` (mirror the same edit — this repo keeps a Vietnamese translation in sync) +- Modify: `ARCHITECTURE.md` (find and update whatever section documents the old stream-json Run feature — search first) +- Modify: `docs/API.md` (the `/api/run/*` endpoint reference) +- Modify: `docs/LANES.md` (mention `ccam lanes shell` next to the existing `ccam lanes pipeline` documentation added in the prior session's work) + +**Interfaces:** none — documentation only. + +- [ ] **Step 1: Find every doc section describing the old Run feature** + +```bash +grep -rln "stream-json\|RunConsole\|headless\|conversation mode\|POST /api/run" README.md README.vi.md ARCHITECTURE.md docs/*.md +``` + +- [ ] **Step 2: Update `README.md`'s bullet** + +Replace: + +```markdown +- **Run Claude from the browser.** Spawn a session in a lane's directory, stream + its output, send follow-ups, resume any past session. +``` + +with: + +```markdown +- **Run Claude from the browser.** A real terminal (tmux + a real PTY, + rendered with xterm.js) attached to a lane's directory — the exact TUI you'd + see locally, fully interactive, resumable, and attachable from a real + terminal too via `ccam lanes shell`. +``` + +- [ ] **Step 3: Mirror the same edit in `README.vi.md`** + +Find the corresponding Vietnamese bullet (search for "Chạy Claude từ trình duyệt") and translate the same change, keeping this repo's existing convention of preserving English technical terms (tmux, PTY, xterm.js, TUI) verbatim. + +- [ ] **Step 4: Update `docs/API.md`'s `/api/run` section** + +Rewrite the endpoint table/prose to match Task 4's actual routes: `GET /api/run`, `GET /api/run/history`, `GET /api/run/binary`, `GET /api/run/tmux`, `GET /api/run/cwds`, `GET /api/run/files`, `POST /api/run` (now requires `laneId`, body shape per `RunStartArgs`), `GET /api/run/:id`, `DELETE /api/run/:id`. Remove the `POST /api/run/:id/message` entry entirely (deleted in Task 4) and any `run_stream`/`run_input_ack` WebSocket message documentation (deleted in Task 7) — add a `/ws-pty/:runId` entry describing the binary-frame PTY transport instead, matching this doc's existing WebSocket-message table format. + +- [ ] **Step 5: Add `ccam lanes shell` to `docs/LANES.md`** + +Find the existing `ccam lanes pipeline` documentation (added in the prior session, per this repo's `docs/LANES.md` "Reporting a stage"/pipeline sections) and add a short paragraph for `ccam lanes shell` directly after it, matching that section's tone: + +```markdown +`ccam lanes shell` attaches a real terminal to the exact tmux session the +dashboard's Start/Resume buttons use for this lane (`ccam-lane-`), +creating it if it doesn't exist yet. Type `claude` inside it like any normal +terminal session — the dashboard's Workspace terminal view is just another +client attached to the same tmux session, so both stay in sync live. +``` + +- [ ] **Step 6: Verify no stale references remain** + +```bash +grep -rln "stream-json\|RunConsole\|useRunStream\|run_stream\b\|run_input_ack" README.md README.vi.md ARCHITECTURE.md docs/*.md +``` + +Expected: empty. + +- [ ] **Step 7: Commit** + +```bash +git add README.md README.vi.md ARCHITECTURE.md docs/API.md docs/LANES.md +git commit -m "docs: update Run feature docs for the tmux+PTY terminal (was stream-json)" +``` + +--- + +### Task 13: Full-suite verification + +**Files:** none — verification only. + +- [ ] **Step 1: Full server suite** + +```bash +npm run test:server 2>&1 | tail -30 +``` + +Expected: all green, including Tasks 2/3/4/5's new test files. + +- [ ] **Step 2: Full client suite** + +```bash +npm run test:client 2>&1 | tail -60 +``` + +Expected: all green, including the regenerated snapshot from Task 10. + +- [ ] **Step 3: Client build and typecheck** + +```bash +cd client && npx tsc --noEmit && npm run build +``` + +Expected: no type errors, build succeeds. + +- [ ] **Step 4: File-header audit (this repo requires it on every touched file)** + +```bash +bash .claude/skills/file-headers/scripts/check-headers.sh +``` + +Expected: exit 0 (aside from the pre-existing, unrelated `.ccam/profile/hooks/*.sh` gaps noted in this repo's own history — do not fix those as part of this plan). + +- [ ] **Step 5: Manual end-to-end click-through (per the spec's "Verify" section)** + +Install `tmux` locally if not already present, then: +1. `npm run dev`, open Workspace for an adopted lane. +2. Click Start — confirm the real Claude Code TUI renders in the browser via `TerminalView`. +3. Type in the browser terminal — confirm input reaches the pane. +4. In a real terminal, run `ccam lanes shell` for the same lane — confirm it drops into the exact same live session (typed output appears in both places). +5. Click Kill on the dashboard — confirm the tmux session ends and the real terminal's attach also exits. +6. Confirm `GET /api/run` no longer lists that run once killed. + +- [ ] **Step 6: Final commit (only if Steps 1-5 turned up fixes)** + +```bash +git add -A +git commit -m "fix: address issues found in full-suite verification" +``` diff --git a/docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md b/docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md new file mode 100644 index 0000000..f6cd7b2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md @@ -0,0 +1,242 @@ +# 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.