From dfea1a99d6ea205848809b21e539393a4714e5c8 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:24:03 +0700 Subject: [PATCH 01/18] fix(tests): scrub GIT_* env vars leaking from the pre-commit hook into git-fixture tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/lib/update-check.js's execGit() and two test helpers (lanes-cli.test.js, update-check.test.js) shelled out to git with an explicit `cwd` but no `env` override. A parent git hook process (this repo's own .husky/pre-commit, which runs `npm run test:server`) sets GIT_DIR/GIT_INDEX_FILE in its own environment; those leak to every child process and take precedence over `cwd` for repo discovery, so every git command these tests ran against their throwaway tmp repos was silently redirected at the real repo running the hook instead — reproduced firsthand as four foreign "init"/"fixture" commits overwriting a worktree branch mid pre-commit run. Fixes it the same way server/lib/worktree.js already documented and did for its own git calls: strip the GIT_* vars before exec. --- server/__tests__/lanes-cli.test.js | 18 ++++++++++++++++- server/__tests__/update-check.test.js | 28 +++++++++++++++++++++++++-- server/lib/update-check.js | 22 ++++++++++++++++++++- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index 844be18..f3fa85b 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -45,9 +45,25 @@ const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js"); let server; let BASE; +// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this +// very suite) sets in its own environment — those leak to every child +// process and override an explicit `cwd`, so without this a git command +// meant for this test's throwaway tmp repo silently operates on the real +// repo running the hook instead. +const GIT_ENV = { ...process.env }; +delete GIT_ENV.GIT_DIR; +delete GIT_ENV.GIT_WORK_TREE; +delete GIT_ENV.GIT_INDEX_FILE; +delete GIT_ENV.GIT_COMMON_DIR; +delete GIT_ENV.GIT_OBJECT_DIRECTORY; +delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES; +delete GIT_ENV.GIT_PREFIX; +delete GIT_ENV.GIT_NAMESPACE; +delete GIT_ENV.GIT_CONFIG_PARAMETERS; + function git(args, cwd) { return new Promise((resolve, reject) => { - const child = spawn("git", args, { cwd }); + const child = spawn("git", args, { cwd, env: GIT_ENV }); let stderr = ""; child.stderr.on("data", (chunk) => (stderr += chunk)); child.on("error", reject); diff --git a/server/__tests__/update-check.test.js b/server/__tests__/update-check.test.js index 844d89a..7efd394 100644 --- a/server/__tests__/update-check.test.js +++ b/server/__tests__/update-check.test.js @@ -14,11 +14,28 @@ const { execFileSync } = require("child_process"); const { getUpdatesStatus } = require("../lib/update-check"); +// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this +// very suite) sets in its own environment — those leak to every child +// process and override an explicit `cwd`, so without this a git command +// meant for this test's throwaway tmp repo silently operates on the real +// repo running the hook instead. +const GIT_ENV = { ...process.env }; +delete GIT_ENV.GIT_DIR; +delete GIT_ENV.GIT_WORK_TREE; +delete GIT_ENV.GIT_INDEX_FILE; +delete GIT_ENV.GIT_COMMON_DIR; +delete GIT_ENV.GIT_OBJECT_DIRECTORY; +delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES; +delete GIT_ENV.GIT_PREFIX; +delete GIT_ENV.GIT_NAMESPACE; +delete GIT_ENV.GIT_CONFIG_PARAMETERS; + function git(cwd, args) { return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", + env: GIT_ENV, }).trim(); } @@ -29,6 +46,7 @@ function makeBareRemote(parent, name) { // i.e. far older than --initial-branch. execFileSync("git", ["-c", "init.defaultBranch=master", "init", "--bare", repo], { stdio: "ignore", + env: GIT_ENV, }); return repo; } @@ -36,7 +54,10 @@ function makeBareRemote(parent, name) { function makeWorkingRepo(parent, dir, originUrl) { const repo = path.join(parent, dir); fs.mkdirSync(repo, { recursive: true }); - execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { stdio: "ignore" }); + execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { + stdio: "ignore", + env: GIT_ENV, + }); fs.writeFileSync(path.join(repo, "README.md"), "fixture\n"); git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]); git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]); @@ -146,7 +167,10 @@ describe("getUpdatesStatus — no remotes configured", () => { it("returns a soft no-remotes payload", async () => { const repo = path.join(tmpDir, "noremote"); fs.mkdirSync(repo, { recursive: true }); - execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { stdio: "ignore" }); + execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { + stdio: "ignore", + env: GIT_ENV, + }); fs.writeFileSync(path.join(repo, "README.md"), "lonely\n"); git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]); git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]); diff --git a/server/lib/update-check.js b/server/lib/update-check.js index dd7448b..37612ac 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -19,13 +19,33 @@ const DEFAULT_ROOT = path.join(__dirname, "..", ".."); // never make the update checker report commits from somebody else's repo. const REMOTE_PRIORITY = ["origin"]; +// Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.) that +// leak from a parent git hook process — e.g. this repo's own pre-commit +// hook, which runs `npm run test:server` and therefore this module too. +// Without this, every git call below silently targets the OUTER repo (the +// hook's) instead of `cwd`, since GIT_DIR takes precedence over cwd-based +// discovery. Same scrub `server/lib/worktree.js` already applies. +const GIT_ENV = { ...process.env }; +delete GIT_ENV.GIT_DIR; +delete GIT_ENV.GIT_WORK_TREE; +delete GIT_ENV.GIT_INDEX_FILE; +delete GIT_ENV.GIT_COMMON_DIR; +delete GIT_ENV.GIT_OBJECT_DIRECTORY; +delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES; +delete GIT_ENV.GIT_PREFIX; +delete GIT_ENV.GIT_NAMESPACE; +delete GIT_ENV.GIT_CONFIG_PARAMETERS; +for (const name of Object.keys(GIT_ENV)) { + if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete GIT_ENV[name]; +} + function execGit(cwd, args, opts = {}) { const timeout = opts.timeout ?? 120_000; return new Promise((resolve, reject) => { execFile( "git", args, - { cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8" }, + { cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8", env: GIT_ENV }, (err, stdout) => { if (err) reject(err); else resolve(String(stdout).trim()); From 00f6338d4cd9a1936d5b7992d29e9f9d0a26d2dd Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:25:34 +0700 Subject: [PATCH 02/18] docs: bring plan and spec into the tmux-terminal-run worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were committed on main's local history but this worktree branched from origin/main, which doesn't have them yet — copying the files in so subagent-driven-development has a plan to read from this branch. --- .../plans/2026-08-12-tmux-terminal-run.md | 2188 +++++++++++++++++ .../2026-08-11-tmux-terminal-run-design.md | 242 ++ 2 files changed, 2430 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-tmux-terminal-run.md create mode 100644 docs/superpowers/specs/2026-08-11-tmux-terminal-run-design.md 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. From d96d552428274a2b46a190fee8d155efe7735f08 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:29:44 +0700 Subject: [PATCH 03/18] chore: add node-pty/xterm deps, tmux in Docker, dashboard_runs.tmux_session column --- Dockerfile | 4 ++++ client/package-lock.json | 17 +++++++++++++++++ client/package.json | 2 ++ package-lock.json | 17 +++++++++++++++++ package.json | 1 + server/db.js | 11 ++++++++++- 6 files changed, 51 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 965cb8b..19d40be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,10 @@ RUN npm run build 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/ COPY package.json ./ COPY server/ ./server/ diff --git a/client/package-lock.json b/client/package-lock.json index 9a1b944..acef41a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "i18next": "^26.0.8", @@ -2100,6 +2102,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", diff --git a/client/package.json b/client/package.json index 62adb1d..82a30e3 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,8 @@ "dependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "i18next": "^26.0.8", diff --git a/package-lock.json b/package-lock.json index 125d804..16bcd97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cross-spawn": "^7.0.6", "express": "^4.21.2", "multer": "^2.0.0", + "node-pty": "^1.1.0", "redoc": "^2.5.3", "swagger-ui-express": "^5.0.1", "tar": "^7.4.3", @@ -1669,6 +1670,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -1701,6 +1708,16 @@ "node": "4.x || >=6.0.0" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-readfiles": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz", diff --git a/package.json b/package.json index f5d86e7..e1c6c3a 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "cross-spawn": "^7.0.6", "express": "^4.21.2", "multer": "^2.0.0", + "node-pty": "^1.1.0", "redoc": "^2.5.3", "swagger-ui-express": "^5.0.1", "tar": "^7.4.3", diff --git a/server/db.js b/server/db.js index a209222..b9b3f08 100644 --- a/server/db.js +++ b/server/db.js @@ -248,7 +248,7 @@ db.exec(` CREATE TABLE IF NOT EXISTS dashboard_runs ( id TEXT PRIMARY KEY, session_id TEXT, - mode TEXT NOT NULL, + mode TEXT, cwd TEXT NOT NULL, model TEXT, permission_mode TEXT, @@ -496,6 +496,15 @@ try { } db.prepare("CREATE INDEX IF NOT EXISTS idx_dashboard_runs_lane ON dashboard_runs(lane_id)").run(); +// 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(); +} + // Migrate: add stage-detection columns to lanes. Inference is never evidence — // these are additive columns separate from `stage` (the declared stage), so // `stage`'s meaning is untouched and turning the feature off loses nothing. From 1dd18fe98c9c8c623bb49edbefddad23d661bf99 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:34:18 +0700 Subject: [PATCH 04/18] feat(run): add tmux command wrapper with an injectable exec seam --- server/__tests__/tmux.test.js | 83 +++++++++++++++++++++++++++++++++++ server/lib/tmux.js | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 server/__tests__/tmux.test.js create mode 100644 server/lib/tmux.js diff --git a/server/__tests__/tmux.test.js b/server/__tests__/tmux.test.js new file mode 100644 index 0000000..951fc23 --- /dev/null +++ b/server/__tests__/tmux.test.js @@ -0,0 +1,83 @@ +/** + * @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); + }); +}); diff --git a/server/lib/tmux.js b/server/lib/tmux.js new file mode 100644 index 0000000..26e91fb --- /dev/null +++ b/server/lib/tmux.js @@ -0,0 +1,82 @@ +/** + * @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, +}; From 56744b360dff9e05249ffbb7e8951b5ace85af0a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:38:25 +0700 Subject: [PATCH 05/18] feat(run): add tmux-backed run lifecycle (spawn/kill/list computed from tmux state) --- server/__tests__/pty-run.test.js | 138 ++++++++++++++++++++++++ server/lib/pty-run.js | 175 +++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 server/__tests__/pty-run.test.js create mode 100644 server/lib/pty-run.js diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js new file mode 100644 index 0000000..66b49a2 --- /dev/null +++ b/server/__tests__/pty-run.test.js @@ -0,0 +1,138 @@ +/** + * @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); + }); +}); diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js new file mode 100644 index 0000000..c9a218b --- /dev/null +++ b/server/lib/pty-run.js @@ -0,0 +1,175 @@ +/** + * @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, +}; From 1bc237198c925fca93ca549062c24253bab2545c Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:46:55 +0700 Subject: [PATCH 06/18] feat(run): rewrite routes for the tmux backend, drop stdin-message endpoint --- server/__tests__/lane-lifecycle.test.js | 15 +- server/__tests__/run.test.js | 514 ++++-------------------- server/routes/run.js | 94 ++--- 3 files changed, 105 insertions(+), 518 deletions(-) diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index 6fbd841..fd2048c 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -1195,22 +1195,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () => }); }); - it("filters GET /api/run/history by laneId and leaves non-lane runs unlabelled", async () => { + it("filters GET /api/run/history by laneId", async () => { const lane = await adoptedLane("history-filter"); const other = await adoptedLane("history-filter-other"); let laneRunId; - let plainRunId; await withFakeClaude("history", "process.exit(0);\n", async () => { const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "lane run" }); laneRunId = started.body.lane.run_id; await waitForRelease(lane.id); - const plain = await request("POST", "/api/run", { - prompt: "plain run", - mode: "headless", - cwd: ROOT, - }); - assert.equal(plain.status, 201); - plainRunId = plain.body.id; }); const filtered = await request("GET", `/api/run/history?laneId=${lane.id}`); @@ -1223,11 +1215,6 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () => const empty = await request("GET", `/api/run/history?laneId=${other.id}`); assert.deepEqual(empty.body.items, []); - - // POST /api/run is unchanged: its row carries no lane. - const all = await request("GET", "/api/run/history?limit=500"); - const plainRow = all.body.items.find((it) => it.id === plainRunId); - assert.equal(plainRow.lane_id, null); }); it("releases the lane when the run exits on its own", async () => { diff --git a/server/__tests__/run.test.js b/server/__tests__/run.test.js index c402170..952470a 100644 --- a/server/__tests__/run.test.js +++ b/server/__tests__/run.test.js @@ -1,28 +1,22 @@ +// server/__tests__/run.test.js /** * @file run.test.js - * @description Tests for the Run feature: spawner injection, route - * validation, same-origin guard, cwd suggestions, resume validation, - * envelope storage / attach, and end-to-end handle lifecycle. Uses a fake - * child (PassThrough streams + EventEmitter) so we never invoke the real - * `claude` binary. + * @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 { PassThrough } = require("node:stream"); -const { EventEmitter } = require("node:events"); -const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-test-")); +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 runs = require("../lib/run-spawner"); -const runRoute = require("../routes/run"); +const tmux = require("../lib/tmux"); let server; let BASE; @@ -66,44 +60,25 @@ function fetchJson(p, opts = {}) { }); } -function makeFakeChild() { - const child = new EventEmitter(); - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); - child.stdin = new PassThrough(); - child.killed = false; - child.kill = function (sig) { - this.killed = true; - setImmediate(() => this.emit("exit", sig === "SIGTERM" ? 143 : 0, sig || null)); - }; - return child; -} - describe("/api/run", () => { before(async () => { const app = createApp(); server = http.createServer(app); await new Promise((r) => server.listen(0, r)); - const port = server.address().port; - BASE = `http://127.0.0.1:${port}`; + BASE = `http://127.0.0.1:${server.address().port}`; }); after(async () => { await new Promise((r) => server.close(r)); - // The SQLite DB lives under TMP and better-sqlite3 holds it open, so on - // Windows rmSync hits EPERM (can't remove a dir with an open handle). - // maxRetries covers transient locks; the try/catch makes the rest - // best-effort — a leftover temp dir must not fail the suite (the OS - // reclaims os.tmpdir()). try { fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { - /* best-effort temp cleanup */ + /* best-effort */ } }); beforeEach(() => { - runs.__reset(); + tmux.__reset(); }); it("rejects cross-origin browser requests", async () => { @@ -115,429 +90,88 @@ describe("/api/run", () => { }); 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.ok(Array.isArray(body.items)); + assert.deepEqual(body.items, []); }); - it("allows localhost Origin", async () => { - const { status } = await fetchJson("/api/run", { - headers: { Origin: "http://localhost:5173" }, + 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("POST / requires prompt", async () => { - const { status, body } = await fetchJson("/api/run", { method: "POST", body: {} }); - assert.equal(status, 400); - assert.equal(body.error.code, "EBADPROMPT"); - }); - - it("POST / rejects non-existent cwd", async () => { - const { status, body } = await fetchJson("/api/run", { - method: "POST", - body: { prompt: "hi", mode: "headless", cwd: "/nope/does/not/exist" }, + 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 ""; }); - assert.equal(status, 400); - assert.equal(body.error.code, "EBADCWD"); - }); - - it("POST / rejects relative cwd", async () => { - const { status, body } = await fetchJson("/api/run", { - method: "POST", - body: { prompt: "hi", mode: "headless", cwd: "./relative" }, - }); - assert.equal(status, 400); - assert.equal(body.error.code, "EBADCWD"); - }); - - it("GET /:id returns 404 for unknown id", async () => { - const { status, body } = await fetchJson("/api/run/does-not-exist"); - assert.equal(status, 404); - assert.equal(body.error.code, "ENOTFOUND"); - }); - - it("DELETE /:id returns 404 for unknown id", async () => { - const { status } = await fetchJson("/api/run/does-not-exist", { method: "DELETE" }); + const { status } = await fetchJson("/api/run/ccam-lane-999", { method: "DELETE" }); assert.equal(status, 404); }); - it("POST /:id/message rejects empty text", async () => { - const { status, body } = await fetchJson("/api/run/x/message", { - method: "POST", - body: {}, - }); - assert.equal(status, 400); - assert.equal(body.error.code, "EBADINPUT"); + 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); }); - // ── /api/run/cwds suggestions ───────────────────────────────────── - - it("GET /cwds returns dashboard + home suggestions with absolute paths", async () => { + 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)); - const kinds = body.items.map((i) => i.kind); - assert.ok(kinds.includes("dashboard"), "dashboard cwd present"); - assert.ok(kinds.includes("home"), "home present"); - for (const it of body.items) { - assert.equal(typeof it.path, "string"); - // path.isAbsolute is platform-aware: "/x" on POSIX, "C:\\x" on Windows. - assert.ok(path.isAbsolute(it.path), "absolute path"); - assert.equal(typeof it.label, "string"); - } - }); - - // ── /api/run/binary probe ───────────────────────────────────────── - - it("GET /binary returns shape { found, path }", async () => { - const { status, body } = await fetchJson("/api/run/binary"); - assert.equal(status, 200); - assert.equal(typeof body.found, "boolean"); - if (body.found) assert.equal(typeof body.path, "string"); - }); - - // ── Resume validation ───────────────────────────────────────────── - - it("POST / rejects bad resumeSessionId format", async () => { - const { status, body } = await fetchJson("/api/run", { - method: "POST", - body: { prompt: "hi", mode: "conversation", resumeSessionId: "x" }, - }); - assert.equal(status, 400); - assert.equal(body.error.code, "EBADSESSION"); - }); - - it("POST / rejects unknown effort level", async () => { - const { status, body } = await fetchJson("/api/run", { - method: "POST", - body: { prompt: "hi", mode: "conversation", effort: "ludicrous" }, - }); - assert.equal(status, 400); - assert.equal(body.error.code, "EBADEFFORT"); - }); - - it("POST / rejects resumeSessionId with headless mode", async () => { - const { status, body } = await fetchJson("/api/run", { - method: "POST", - body: { - prompt: "hi", - mode: "headless", - resumeSessionId: "deadbeef-cafe-1234-5678-feedfacefeed", - }, - }); - assert.equal(status, 400); - assert.equal(body.error.code, "EBADMODE"); - }); - - // ── HTTP GET /:id?envelopes=1 (attach payload) ──────────────────── - - it("GET /:id?envelopes=1 returns the in-memory envelope log", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - fake.stdout.write(`{"type":"system","subtype":"init","session_id":"sX"}\n`); - await new Promise((r) => setImmediate(r)); - const { status, body } = await fetchJson(`/api/run/${handle.id}?envelopes=1`); - assert.equal(status, 200); - assert.ok(Array.isArray(body.envelopes)); - assert.equal(body.envelopes.length, 1); - assert.equal(body.envelopes[0].type, "system"); - }); - - it("GET /files returns paths matching q, skipping node_modules", async () => { - // Build a tiny fixture under tmp so the test is hermetic. - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "run-files-")); - fs.mkdirSync(path.join(tmp, "src")); - fs.mkdirSync(path.join(tmp, "node_modules", "leftover-pkg"), { recursive: true }); - fs.writeFileSync(path.join(tmp, "README.md"), "x"); - fs.writeFileSync(path.join(tmp, "src", "index.ts"), "x"); - fs.writeFileSync(path.join(tmp, "node_modules", "leftover-pkg", "x.js"), "x"); - try { - const { status, body } = await fetchJson( - `/api/run/files?cwd=${encodeURIComponent(tmp)}&q=index` - ); - assert.equal(status, 200); - assert.deepEqual(body.items.sort(), ["src/index.ts"]); - // No q → returns top-level files (excluding node_modules) - const all = await fetchJson(`/api/run/files?cwd=${encodeURIComponent(tmp)}`); - assert.ok(all.body.items.includes("README.md")); - assert.ok(!all.body.items.some((p) => p.startsWith("node_modules"))); - } finally { - try { - fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); - } catch { - /* best-effort temp cleanup (Windows may hold a handle) */ - } - } - }); - - it("GET /files rejects missing/invalid cwd", async () => { - const { status, body } = await fetchJson("/api/run/files?cwd=/does/not/exist"); - assert.equal(status, 400); - assert.equal(body.error.code, "EBADCWD"); - }); - - it("GET /:id without ?envelopes returns metadata only", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - fake.stdout.write(`{"type":"system","subtype":"init"}\n`); - await new Promise((r) => setImmediate(r)); - const { body } = await fetchJson(`/api/run/${handle.id}`); - assert.equal(body.envelopes, undefined); - assert.equal(body.envelopeCount, 1); - }); -}); - -describe("run-spawner unit", () => { - beforeEach(() => { - runs.__reset(); - }); - - it("injected child parses stream-json envelopes and broadcasts", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - fake.stdout.write( - `{"type":"system","subtype":"init","session_id":"sess-abc","model":"opus"}\n` - ); - fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`); - // Allow the line parser to flush - await new Promise((r) => setImmediate(r)); - const live = runs.getRun(handle.id); - assert.equal(live.status, "running"); - assert.equal(live.sessionId, "sess-abc"); - assert.equal(live.envelopeCount, 2); - }); - - it("sendInput writes a stream-json envelope to stdin", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - // Force into running state via a parsed envelope first - fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`); - await new Promise((r) => setImmediate(r)); - const chunks = []; - fake.stdin.on("data", (c) => chunks.push(c.toString())); - runs.sendInput(handle.id, "follow-up"); - await new Promise((r) => setImmediate(r)); - const written = chunks.join(""); - const lines = written.trim().split("\n"); - const obj = JSON.parse(lines[lines.length - 1]); - assert.equal(obj.type, "user"); - assert.equal(obj.message.content, "follow-up"); - }); - - it("sendInput rejects on headless handles", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "headless" }); - fake.stdout.write(`{"type":"system","subtype":"init"}\n`); - await new Promise((r) => setImmediate(r)); - assert.throws(() => runs.sendInput(handle.id, "x"), /only conversation mode/); - }); - - it("kill marks handle as killed and emits exit", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake }); - runs.killRun(handle.id); - await new Promise((r) => setImmediate(r)); - const live = runs.getRun(handle.id); - assert.equal(live.status, "killed"); - }); - - it("escalates to SIGKILL when SIGTERM was delivered but the child has not exited", async () => { - const fake = makeFakeChild(); - const signals = []; - fake.kill = function (signal) { - this.killed = true; - signals.push(signal); - if (signal === "SIGKILL") setImmediate(() => this.emit("exit", 137, signal)); - return true; - }; - const handle = runs.__injectChildForTest({ child: fake }); - const originalSetTimeout = global.setTimeout; - global.setTimeout = (callback, delay, ...args) => { - if (delay === 5000) { - callback(...args); - return { unref: () => {} }; - } - return originalSetTimeout(callback, delay, ...args); - }; - try { - runs.killRun(handle.id); - } finally { - global.setTimeout = originalSetTimeout; - } - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]); - assert.notEqual(runs.getRun(handle.id).actualExitedAt, null); - }); - - it("exit with code 0 marks completed", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake }); - fake.emit("exit", 0, null); - await new Promise((r) => setImmediate(r)); - const live = runs.getRun(handle.id); - assert.equal(live.status, "completed"); - assert.equal(live.exitCode, 0); - }); - - it("exit with non-zero code marks error", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake }); - fake.emit("exit", 1, null); - await new Promise((r) => setImmediate(r)); - const live = runs.getRun(handle.id); - assert.equal(live.status, "error"); - }); - - it("malformed JSON lines do not crash; go to stderr buffer", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake }); - fake.stdout.write("not valid json\n"); - await new Promise((r) => setImmediate(r)); - const live = runs.getRun(handle.id); - assert.match(live.stderrTail, /parse-error/); - }); - - it("listRuns returns handles sorted newest first", async () => { - const a = runs.__injectChildForTest({ child: makeFakeChild() }); - await new Promise((r) => setTimeout(r, 5)); - const b = runs.__injectChildForTest({ child: makeFakeChild() }); - const list = runs.listRuns(); - assert.equal(list[0].id, b.id); - assert.equal(list[1].id, a.id); - }); -}); - -describe("sameOriginGuard helper", () => { - it("loopback Origin passes", () => { - const next = () => "OK"; - const res = {}; - const result = runRoute.__sameOriginGuard( - { headers: { origin: "http://127.0.0.1:4820" } }, - res, - next - ); - assert.equal(result, "OK"); - }); - it("missing Origin passes (CLI use case)", () => { - const next = () => "OK"; - const result = runRoute.__sameOriginGuard({ headers: {} }, {}, next); - assert.equal(result, "OK"); - }); - it("non-loopback Origin is blocked", () => { - let captured = null; - const res = { - status(code) { - captured = { code }; - return this; - }, - json(body) { - captured.body = body; - return this; - }, - }; - runRoute.__sameOriginGuard({ headers: { origin: "http://attacker.com" } }, res, () => {}); - assert.equal(captured.code, 403); - assert.equal(captured.body.error.code, "EBADORIGIN"); - }); -}); - -describe("run-spawner extras", () => { - beforeEach(() => { - runs.__reset(); - }); - - it("getRun (no opts) returns metadata only — no envelopes field", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`); - fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`); - await new Promise((r) => setImmediate(r)); - const live = runs.getRun(handle.id); - assert.equal(live.envelopeCount, 2); - assert.equal(live.envelopes, undefined); - }); - - it("getRun({includeEnvelopes:true}) returns the in-memory log", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - fake.stdout.write(`{"type":"system","subtype":"init"}\n`); - fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"x"}]}}\n`); - await new Promise((r) => setImmediate(r)); - const live = runs.getRun(handle.id, { includeEnvelopes: true }); - assert.ok(Array.isArray(live.envelopes)); - assert.equal(live.envelopes.length, 2); - assert.equal(live.envelopes[0].type, "system"); - }); - - it("listRuns surfaces resumeSessionId (null for fresh)", async () => { - runs.__injectChildForTest({ child: makeFakeChild(), mode: "conversation" }); - const list = runs.listRuns(); - assert.equal(list.length, 1); - assert.equal(list[0].resumeSessionId, null); - }); - - it("killRun is idempotent on already-completed handles", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake }); - fake.emit("exit", 0, null); - await new Promise((r) => setImmediate(r)); - assert.equal(runs.getRun(handle.id).status, "completed"); - // Second kill on a completed handle should be a safe no-op (returns true). - assert.equal(runs.killRun(handle.id), true); - assert.equal(runs.getRun(handle.id).status, "completed"); - }); - - it("killRun returns false for an unknown id", () => { - assert.equal(runs.killRun("does-not-exist"), false); - }); - - it("sendInput throws ENOTFOUND for unknown id", () => { - assert.throws(() => runs.sendInput("nope", "hi"), /not found/); - }); - - it("sendInput throws ENOTRUNNING when handle has already exited", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - fake.emit("exit", 0, null); - await new Promise((r) => setImmediate(r)); - assert.throws(() => runs.sendInput(handle.id, "x"), /run is (completed|killed|error)/); - }); - - it("sendInput rejects empty text", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - fake.stdout.write(`{"type":"system","subtype":"init"}\n`); - await new Promise((r) => setImmediate(r)); - assert.throws(() => runs.sendInput(handle.id, ""), /text is required/); - }); - - it("envelope log is capped at 500 entries", async () => { - const fake = makeFakeChild(); - const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" }); - let line = ""; - for (let i = 0; i < 600; i++) line += `{"type":"assistant","i":${i}}\n`; - fake.stdout.write(line); - await new Promise((r) => setImmediate(r)); - const live = runs.getRun(handle.id, { includeEnvelopes: true }); - assert.equal(live.envelopeCount, 600); - assert.equal(live.envelopes.length, 500); - // The cap drops the OLDEST entries — last entry should be the latest. - assert.equal(live.envelopes[live.envelopes.length - 1].i, 599); - }); - - it("getMaxConcurrent respects RUN_MAX_CONCURRENT env override", () => { - const orig = process.env.RUN_MAX_CONCURRENT; - try { - process.env.RUN_MAX_CONCURRENT = "7"; - assert.equal(runs.getMaxConcurrent(), 7); - process.env.RUN_MAX_CONCURRENT = "garbage"; - assert.ok(runs.getMaxConcurrent() >= 1, "falls back to default on non-numeric"); - delete process.env.RUN_MAX_CONCURRENT; - assert.ok(runs.getMaxConcurrent() >= 1); - } finally { - if (orig != null) process.env.RUN_MAX_CONCURRENT = orig; - else delete process.env.RUN_MAX_CONCURRENT; - } }); }); diff --git a/server/routes/run.js b/server/routes/run.js index 957616b..d4dfeec 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -1,9 +1,9 @@ /** * @file run.js - * @description HTTP routes for the dashboard's Run feature. Spawns and - * supervises `claude` processes (headless one-shot or multi-turn - * conversation), streams structured envelopes to the client over the - * existing WebSocket, and exposes a tiny CRUD-ish surface for run management. + * @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. * * Security model: * - Local-first dashboard. The dashboard server is expected to bind to @@ -22,7 +22,8 @@ const { Router } = require("express"); const fs = require("node:fs"); const path = require("node:path"); -const runs = require("../lib/run-spawner"); +const runs = require("../lib/pty-run"); +const tmux = require("../lib/tmux"); const router = Router(); @@ -96,11 +97,7 @@ function sanitiseCwd(input) { const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); router.get("/", (_req, res) => { - res.json({ - items: runs.listRuns(), - maxConcurrent: runs.getMaxConcurrent(), - activeCount: runs.liveCount(), - }); + res.json({ items: runs.listRuns() }); }); /** @@ -125,12 +122,7 @@ router.get("/history", (req, res) => { limit: Number.isFinite(limit) ? limit : 50, laneId: Number.isFinite(laneId) ? laneId : null, }); - // 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); - } + const liveIds = new Set(runs.listRuns().map((h) => h.id)); res.json({ items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })), }); @@ -260,23 +252,15 @@ router.get("/binary", (_req, res) => { }); }); +router.get("/tmux", (_req, res) => { + res.json({ available: tmux.isTmuxAvailable() }); +}); + router.post("/", (req, res) => { const body = req.body || {}; - const prompt = typeof body.prompt === "string" ? body.prompt : ""; - const mode = body.mode === "headless" ? "headless" : "conversation"; - const model = typeof body.model === "string" && body.model ? body.model : null; - const resumeSessionId = - typeof body.resumeSessionId === "string" && body.resumeSessionId ? body.resumeSessionId : null; - const effort = typeof body.effort === "string" && body.effort ? body.effort : null; - const permissionMode = - typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode) - ? body.permissionMode - : "acceptEdits"; - // Resuming a conversation can spawn with an empty prompt — claude waits - // on stdin until the user types a follow-up. Headless and fresh - // conversation runs still need a prompt to do anything. - if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) { - return res.status(400).json({ error: { code: "EBADPROMPT", message: "prompt is required" } }); + 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 { @@ -286,22 +270,22 @@ router.post("/", (req, res) => { } try { const handle = runs.spawnRun({ - prompt, - mode, + laneId, cwd, - model, - permissionMode, - resumeSessionId, - effort, + 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(runs.getRun(handle.id)); + return res.status(201).json(handle); } catch (err) { - if (err.code === "ECONCURRENCY") { - return res.status(429).json({ - error: { code: err.code, message: err.message }, - running: err.running || [], - }); - } if (err.code && err.code.startsWith("E")) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } @@ -309,27 +293,9 @@ router.post("/", (req, res) => { } }); -router.post("/:id/message", (req, res) => { - const body = req.body || {}; - const text = typeof body.text === "string" ? body.text : ""; - if (!text) { - return res.status(400).json({ error: { code: "EBADINPUT", message: "text is required" } }); - } - try { - const result = runs.sendInput(req.params.id, text); - return res.json(result); - } catch (err) { - const status = err.code === "ENOTFOUND" ? 404 : 400; - return res.status(status).json({ error: { code: err.code, message: err.message } }); - } -}); - router.get("/:id", (req, res) => { - // ?envelopes=1 includes the in-memory envelope history so the UI can - // re-attach to an active run started elsewhere and see what it missed. - const includeEnvelopes = req.query.envelopes === "1"; - const handle = runs.getRun(req.params.id, { includeEnvelopes }); - if (!handle) { + 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); From 872c698132aa2e0ee4f105ad1c872c1cd3b9f78a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 10:13:14 +0700 Subject: [PATCH 07/18] feat(run): add /ws-pty/:runId PTY transport bridging WS to tmux attach --- server/__tests__/pty-attach.test.js | 105 ++++++++++++++++++++++++++++ server/index.js | 3 +- server/lib/pty-attach.js | 104 +++++++++++++++++++++++++++ server/websocket.js | 93 +++++++++++++++++++++--- 4 files changed, 293 insertions(+), 12 deletions(-) create mode 100644 server/__tests__/pty-attach.test.js create mode 100644 server/lib/pty-attach.js diff --git a/server/__tests__/pty-attach.test.js b/server/__tests__/pty-attach.test.js new file mode 100644 index 0000000..8482438 --- /dev/null +++ b/server/__tests__/pty-attach.test.js @@ -0,0 +1,105 @@ +/** + * @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); + }); +}); diff --git a/server/index.js b/server/index.js index 04687bf..2410efe 100644 --- a/server/index.js +++ b/server/index.js @@ -33,7 +33,7 @@ const cors = require("cors"); const path = require("path"); const http = require("http"); const swaggerUi = require("swagger-ui-express"); -const { initWebSocket } = require("./websocket"); +const { initWebSocket, initPtyWebSocket } = require("./websocket"); const { createOpenApiSpec } = require("./openapi"); const { redocBundlePath, renderRedocHtml } = require("./lib/redoc"); const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./lib/server-info"); @@ -150,6 +150,7 @@ function createApp() { function startServer(app, port) { const server = http.createServer(app); initWebSocket(server); + initPtyWebSocket(server); const isProduction = process.env.NODE_ENV === "production"; if (isProduction) { diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js new file mode 100644 index 0000000..ed1948f --- /dev/null +++ b/server/lib/pty-attach.js @@ -0,0 +1,104 @@ +/** + * @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(`EBADRUNID: 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 }; diff --git a/server/websocket.js b/server/websocket.js index c4fbe4a..6ca4423 100644 --- a/server/websocket.js +++ b/server/websocket.js @@ -59,6 +59,61 @@ function initWebSocket(server) { return wss; } +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; +} + function broadcast(type, data) { if (!wss) return; const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() }); @@ -90,20 +145,36 @@ function getConnectionCount() { * clients first lets the HTTP server drain and close promptly. */ function closeWebSocket() { - if (!wss) return; - wss.clients.forEach((client) => { + if (wss) { + wss.clients.forEach((client) => { + try { + client.terminate(); + } catch { + /* already gone */ + } + }); try { - client.terminate(); + wss.close(); } catch { - /* already gone */ + /* ignore */ } - }); - 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; } - wss = null; } -module.exports = { initWebSocket, broadcast, getConnectionCount, closeWebSocket }; +module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket }; From 9b8d9bbe397ae8e98549188a0b05e191dba1e369 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 10:19:10 +0700 Subject: [PATCH 08/18] feat(run): add TerminalView xterm.js component for the PTY transport - TerminalView.tsx: xterm.js component with WebSocket attachment to /ws-pty/:runId - Test: validates WS connection URL, incoming terminal data, and outgoing keystrokes - Added ResizeObserver stub to test-setup.ts for jsdom environment --- client/src/components/run/TerminalView.tsx | 77 +++++++++++++++++ .../run/__tests__/TerminalView.test.tsx | 84 +++++++++++++++++++ client/src/test-setup.ts | 7 ++ 3 files changed, 168 insertions(+) create mode 100644 client/src/components/run/TerminalView.tsx create mode 100644 client/src/components/run/__tests__/TerminalView.test.tsx diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx new file mode 100644 index 0000000..bb48548 --- /dev/null +++ b/client/src/components/run/TerminalView.tsx @@ -0,0 +1,77 @@ +/** + * @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
; +} diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx new file mode 100644 index 0000000..3dfb73d --- /dev/null +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -0,0 +1,84 @@ +/** + * @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, afterEach } 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); + return { dispose: vi.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"]); + }); +}); diff --git a/client/src/test-setup.ts b/client/src/test-setup.ts index ea0c636..8a750c9 100644 --- a/client/src/test-setup.ts +++ b/client/src/test-setup.ts @@ -16,6 +16,13 @@ import { afterEach, beforeEach } from "vitest"; import "./i18n/index"; import i18n from "i18next"; +/** jsdom does not implement ResizeObserver — stub it for components that use it. */ +// @ts-expect-error global stub +global.ResizeObserver = class { + observe() {} + disconnect() {} +}; + /** Pin locale to English — LanguageDetector may otherwise pick up zh/vi from the host OS. */ beforeEach(() => { i18n.changeLanguage("en"); From 24f13911fe6bfe7d144567391a25680ad3fbe925 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 10:24:45 +0700 Subject: [PATCH 09/18] feat(run): replace RunHandle/RunStartArgs types and api.run for the tmux backend --- client/src/lib/api.ts | 177 ++++++++-------------------------------- client/src/lib/types.ts | 53 +++--------- 2 files changed, 42 insertions(+), 188 deletions(-) diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 268222e..c8e5cc4 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1474,110 +1474,32 @@ export const api = { /** Spawn/manage headless or conversational `claude` CLI child processes * launched from the dashboard's Run page, and stream their output. */ run: { - /** - * GET /api/run - currently tracked runs (in-memory handles) plus - * concurrency limits. - * @returns {@link RunListResponse} — live handles + `maxConcurrent`/`activeCount`. - */ + /** 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 the `dashboard_runs` - * table, including runs whose in-memory handle has since been reaped. - * Optionally filter by lane. - * - * `limit` defaults to 50 when the caller omits it and is always sent as a - * query param. - * - * @param limit Max history rows to return (default 50). - * @param options Optional filters like laneId. - * @returns `{ items }` — {@link DashboardRunHistoryItem} rows, newest-first. - */ + /** 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 a `claude` executable was found on PATH. - * - * Lets the Run page disable/enable the "start" affordance and show where the - * CLI resolved from (or that it's missing). - * - * @returns `{ found, path }` — whether a binary was located and its path. - */ + /** GET /api/run/binary - whether `claude` was found on PATH. */ binary: () => request<{ found: boolean; path: string | null }>("/run/binary"), - /** - * GET /api/run/cwds - suggested working directories for the cwd picker. - * @returns `{ items }` — {@link CwdSuggestion} entries (dashboard/home/recent). - */ + /** 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`, filtered - * by an optional query fragment `q`. - * - * Backs the file/@-mention autocomplete when composing a run prompt: `cwd` - * is always sent; `q` is appended only when non-empty to narrow matches. - * - * @param cwd The directory to complete paths within. - * @param q Optional partial fragment to filter suggestions by. - * @returns `{ items }` — matching path strings under `cwd`. - */ + /** 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 - spawn a new `claude` child process. - * - * Sends {@link RunStartArgs} (prompt, mode, and optional cwd/model/ - * permission-mode/resume/effort). The server spawns the CLI and returns the - * initial {@link RunHandle}; subsequent output is streamed over the - * `run_stream` WebSocket message rather than this response. - * - * @param args The spawn parameters. - * @returns {@link RunHandle} — the freshly created run's handle. - */ + /** 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; pass `envelopes: true` to - * also include its buffered stream-json envelopes (for a page refresh - * mid-run, since the WS `run_stream` history isn't otherwise replayed). - * - * The `envelopes` flag is translated to `?envelopes=1`. Use it when - * re-hydrating the Run page after a reload: the WebSocket only pushes *new* - * envelopes, so the buffered ones must be pulled once to backfill the view. - * - * @param id The run id. - * @param opts Optional `{ envelopes }` — include buffered stream-json envelopes. - * @returns {@link RunHandle} — the run's handle (with `envelopes` when requested). - */ - get: (id: string, opts?: { envelopes?: boolean }) => - request(`/run/${encodeURIComponent(id)}${opts?.envelopes ? "?envelopes=1" : ""}`), - /** - * POST /api/run/:id/message - write `text` to the run's stdin (conversation - * mode only); acked via the `run_input_ack` WS message. - * - * Only meaningful for a run started in "conversation" mode (stdin left - * open). The HTTP response returns just the `messageId`; the actual - * delivery/echo is confirmed asynchronously over the WebSocket. - * - * @param id The run id to send input to. - * @param text The user's follow-up message written to the CLI's stdin. - * @returns `{ messageId }` — id correlating this input with its `run_input_ack`. - */ - send: (id: string, text: string) => - request<{ messageId: string }>(`/run/${encodeURIComponent(id)}/message`, { - method: "POST", - body: JSON.stringify({ text }), - }), - /** - * DELETE /api/run/:id - forcibly terminate a running process. - * - * @param id The run id to kill. - * @returns `{ ok: true }` — acknowledgement that termination was requested. - */ + /** 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" }), }, @@ -2506,78 +2428,49 @@ export interface CcHookScripts { // mirror the CLI's own vocabulary so the dashboard can drive the CLI faithfully. // ───────────────────────────────────────────────────────────────────────────── -/** "headless" runs to completion unattended and streams only output; - * "conversation" keeps stdin open so the user can send follow-up messages. */ -export type RunMode = "headless" | "conversation"; -/** Lifecycle of a spawned `claude` process, mirrored in `RunHandle.status` - * and `RunStatusPayload.status`. "abandoned" is applied by server cleanup - * when a handle is reaped without a clean exit ever being observed. */ -export type RunStatus = "spawning" | "running" | "completed" | "error" | "killed" | "abandoned"; /** 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 spawning a new `claude` process. */ +/** Body for POST /api/run - parameters for starting a lane's terminal run. */ export interface RunStartArgs { - /** Initial prompt/task text passed to the CLI. */ - prompt: string; - mode: RunMode; - /** Working directory to launch in; server default applies if omitted. */ + laneId: number; cwd?: string; - /** `--model` value; omitted inherits the CLI's own default (settings.json). */ 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; } -/** In-memory (or freshly-fetched) handle for one spawned `claude` process, - * from POST/GET /api/run - the live counterpart to {@link DashboardRunHistoryItem}. - * Where {@link DashboardRunHistoryItem} is the persisted DB row (snake_case, - * survives handle reaping), this is the richer live handle (camelCase, carries - * argv/tails/envelope counters) that only exists while the server tracks it. */ +/** A lane's tmux-backed terminal run — one per lane, id is the tmux session + * name (`ccam-lane-`). */ export interface RunHandle { id: string; - /** OS process id; null before the process has actually spawned. */ - pid: number | null; - mode: RunMode; - cwd: string; - model: string | null; - permissionMode: PermissionMode; - effort: EffortLevel | null; - prompt: string; - /** Full argv the server invoked the CLI with, for debugging. */ - argv: string[]; - resumeSessionId: string | null; + laneId: number | null; status: RunStatus; - /** Epoch-ms timestamp the process was spawned. */ - startedAt: number; - /** Epoch-ms timestamp the process exited; null while still running. */ - endedAt: number | null; - exitCode: number | null; - /** POSIX signal that killed the process (e.g. "SIGTERM"); null otherwise. */ - signal: string | null; - error: string | null; - /** Claude Code session id the run created/resumed, once known. */ + 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; - /** Count of stream-json envelopes emitted so far. */ - envelopeCount: number; - /** Last chunk of captured stdout, for a quick inline preview. */ - stdoutTail: string; - /** Last chunk of captured stderr, for a quick inline preview. */ - stderrTail: string; - envelopes?: unknown[]; // present when fetched with ?envelopes=1 + /** ISO timestamp the tmux session was created. */ + startedAt: string | null; } /** Response shape of GET /api/run. */ export interface RunListResponse { items: RunHandle[]; - /** Server-configured cap on simultaneously running processes. */ - maxConcurrent: number; - /** Count of runs currently in "spawning"/"running" state. */ - activeCount: number; } /** @@ -2591,23 +2484,17 @@ export interface RunListResponse { */ export interface DashboardRunHistoryItem { id: string; - /** Claude Code session id the run created/resumed; null if never captured. */ session_id: string | null; - mode: RunMode; cwd: string; model: string | null; permission_mode: PermissionMode | null; effort: EffortLevel | null; resume_session_id: string | null; - /** Truncated leading excerpt of the original prompt, for the history list. */ prompt_preview: string | null; - status: RunStatus; + status: "running" | "killed" | "abandoned"; exit_code: number | null; started_at: string; ended_at: string | null; - /** True when an in-memory {@link RunHandle} for this row still exists (so - * the UI can offer live actions like "send message"/"kill"); false once - * the handle has been reaped and only the DB row remains. */ isLive: boolean; } diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index a65d90c..07068c9 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1277,52 +1277,19 @@ export interface UpdateStatusPayload { fetch_error?: string; } -// ───── Interactive run streaming ───── -// Payloads for the "run a `claude` process from the dashboard" feature. A run is -// started via POST /api/run and identified by a `RunHandle` id; the server then -// streams stdout envelopes, status transitions, and stdin acks back over the WS. +// ───── 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_stream` WebSocket message: one streamed JSON envelope - * from a headless/conversation `claude` process started via POST /api/run. */ -export interface RunStreamPayload { - /** Id of the `RunHandle` this envelope belongs to. Lets the UI route the chunk - * to the right run panel when several runs stream at once. */ - id: string; - /** Raw stream-json envelope emitted by the Claude Code CLI (assistant text - * deltas, tool_use/tool_result blocks, etc.) - shape varies by event type. - * Typed as `unknown` because it's forwarded verbatim and narrowed at render. */ - envelope: unknown; -} -/** Payload for the `run_status` WebSocket message: a lifecycle transition for - * a run started via POST /api/run (mirrors `RunHandle.status`). */ +/** Payload for the `run_status` WebSocket message. */ export interface RunStatusPayload { - /** Id of the `RunHandle` whose status changed. */ + /** The run id (tmux session name, `ccam-lane-`). */ id: string; - /** New run lifecycle state; terminal states are "completed"/"error"/"killed". - * "spawning" → the child is being started; "running" → streaming output; - * "killed" → the run was cancelled by the user. */ - status: "spawning" | "running" | "completed" | "error" | "killed"; - /** Epoch-ms timestamp of this status transition (NOT an ISO string, unlike - * most timestamps in this file). */ - at: number; - /** Process exit code; present once status reaches "completed"/"error". 0 means - * a clean exit. */ - exitCode?: number; - /** Claude Code session id resumed/created by this run, once known. Lets the UI - * link a run to the {@link Session} it produced. */ - sessionId?: string | null; - /** Failure message; present when status is "error". Surfaced in the run panel. */ - error?: string; -} -/** Payload for the `run_input_ack` WebSocket message: confirms a message sent - * via POST /api/run/:id/message was written to the child process's stdin. */ -export interface RunInputAckPayload { - /** Id of the `RunHandle` the input was delivered to. */ - id: string; - /** Echoes the id returned by the `send` call this acks, so the UI can clear - * the matching "sending…" pending state. */ - messageId: string; - /** Epoch-ms timestamp the input was delivered (not an ISO string). */ + status: "running" | "gone"; + /** Epoch-ms timestamp of this transition. */ at: number; } From 82bf803c2ec8cb8ef8e585350913437ef72cad25 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 10:53:23 +0700 Subject: [PATCH 10/18] fix(lanes): bridge routes/lanes.js to pty-run.js Replace run-spawner imports and APIs with pty-run: - Import pty-run instead of run-spawner - Delete setRunExitHandler registration, replace with read-time self-heal in payload() - Remove mode validation (mode no longer exists in pty-run) - Update spawnRun call to use new parameter names (initialPrompt, not prompt/mode) - Replace "message" action with explicit 400 EUNSUPPORTED response - Fix stopLaneRun to poll on status !== "gone" instead of !actualExitedAt Adapt tests to tmux-based run model: - Delete tests about mode-specific behavior (removed feature) - Rewrite lane release tests using tmux.__setExecImpl mocks instead of withFakeClaude - Update assertions to check status === "gone" instead of specific exit codes - Update ERUNTIMEOUT test to mock tmux sessions instead of child processes All lane-related tests pass; only pre-existing port conflicts in lane-detect.test.js remain. --- server/__tests__/lane-lifecycle.test.js | 325 +++++++----------------- server/__tests__/lanes-api.test.js | 8 +- server/routes/lanes.js | 88 +++---- 3 files changed, 134 insertions(+), 287 deletions(-) diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index fd2048c..d2d0053 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -24,7 +24,7 @@ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lifecycle-")); process.env.LANES_ROOT = path.join(ROOT, "lanes"); const { createApp, startServer } = require("../index"); -const runs = require("../lib/run-spawner"); +const runs = require("../lib/pty-run"); let server; let BASE; @@ -774,7 +774,7 @@ describe("destructive lane lifecycle actions", () => { await request("DELETE", `/api/lanes/${lane.id}`); }); - it("waits for the run-spawner child's actual exit before resetting its worktree", async () => { + it("waits for the tmux session to exit before resetting its worktree", async () => { const lane = await createManagedLane("await-real-exit"); fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n"); const bin = path.join(ROOT, "run-exit-bin"); @@ -803,7 +803,7 @@ describe("destructive lane lifecycle actions", () => { expect: destructiveExpect(preflight.body), }); assert.equal(reset.status, 200); - assert.notEqual(runs.getRun(runId).actualExitedAt, null); + assert.equal(runs.getRun(runId).status, "gone"); assert.equal(fs.existsSync(path.join(lane.cwd, "written-by-run.txt")), false); } finally { process.env.PATH = originalPath; @@ -811,58 +811,47 @@ describe("destructive lane lifecycle actions", () => { await request("DELETE", `/api/lanes/${lane.id}`); }); - it("resets after a lane run fails to spawn because that handle is already exited", async () => { - const lane = await createManagedLane("failed-spawn-reset"); - const originalPath = process.env.PATH; - const emptyBin = path.join(ROOT, "empty-bin"); - fs.mkdirSync(emptyBin, { recursive: true }); - process.env.PATH = emptyBin; - try { - const started = await request("POST", `/api/lanes/${lane.id}/start`, { - prompt: "cannot spawn", - }); - assert.equal(started.status, 200); - const runId = started.body.lane.run_id; - const deadline = Date.now() + 1000; - while (!runs.getRun(runId).actualExitedAt && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - assert.notEqual(runs.getRun(runId).actualExitedAt, null); - process.env.PATH = originalPath; + it("returns ERUNTIMEOUT and leaves the worktree untouched when a tmux session never exits", async () => { + const tmux = require("../lib/tmux"); + const lane = await createManagedLane("await-timeout"); + const sentinel = path.join(lane.cwd, "must-survive-timeout.txt"); + fs.writeFileSync(sentinel, "still here\n"); + // Start a run for the lane + const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" }); + assert.equal(started.status, 200); + const runId = started.body.lane.run_id; + + // Mock tmux so has-session always returns 0 (session exists), simulating a stuck session + tmux.__setExecImpl(async (cmd, args) => { + if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) { + return { code: 0, stdout: "", stderr: "" }; + } + if (cmd === "tmux" && args[0] === "list-sessions") { + return { code: 0, stdout: "", stderr: "" }; + } + if (cmd === "tmux" && args[0] === "kill-session" && args[1] === "-t" && args[2] === runId) { + return { code: 0, stdout: "", stderr: "" }; + } + return { code: 1, stdout: "", stderr: "" }; + }); + + try { const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`); const reset = await request("POST", `/api/lanes/${lane.id}/reset`, { confirm: true, force: true, expect: destructiveExpect(preflight.body), }); - assert.equal(reset.status, 200); + assert.equal(reset.status, 500); + assert.equal(reset.body.error.code, "ERUNTIMEOUT"); + assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n"); } finally { - process.env.PATH = originalPath; + tmux.__reset(); } await request("DELETE", `/api/lanes/${lane.id}`); }); - it("returns ERUNTIMEOUT and leaves the worktree untouched when a run never exits", async () => { - const lane = await createManagedLane("await-timeout"); - const sentinel = path.join(lane.cwd, "must-survive-timeout.txt"); - fs.writeFileSync(sentinel, "still here\n"); - const child = makeRunChild({ exitsOnKill: false }); - const handle = runs.__injectChildForTest({ child }); - await request("PATCH", `/api/lanes/${lane.id}`, { run_id: handle.id }); - const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`); - - const reset = await request("POST", `/api/lanes/${lane.id}/reset`, { - confirm: true, - force: true, - expect: destructiveExpect(preflight.body), - }); - assert.equal(reset.status, 500); - assert.equal(reset.body.error.code, "ERUNTIMEOUT"); - assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n"); - await request("DELETE", `/api/lanes/${lane.id}`); - }); - it("removes a lane whose worktree was deleted by hand, taking the prune path", async () => { // The design promises "the lane reports `missing` and only `remove` is // offered, taking the prune path". Before this, `remove` hit check 2, which @@ -892,29 +881,42 @@ describe("destructive lane lifecycle actions", () => { assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), ""); }); - it("refuses a second start while the first run is still live, so no child is orphaned", async () => { - // Overwriting run_id while its child is alive orphans that child: a later - // reset kills and awaits only the RECORDED run, then `git clean -fd` the - // directory the orphan is still writing into. + it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => { + // Overwriting run_id while its tmux session is alive orphans that session: a later + // reset kills and awaits only the RECORDED run's session, then `git clean -fd` the + // directory the orphan session is still using. + const tmux = require("../lib/tmux"); const lane = await createManagedLane("start-twice"); - const child = makeRunChild({ exitsOnKill: true }); - const handle = runs.__injectChildForTest({ child }); - await request("PATCH", `/api/lanes/${lane.id}`, { run_id: handle.id }); - assert.equal(runs.getRun(handle.id).status, "spawning"); - const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" }); - assert.equal(second.status, 409); - assert.equal(second.body.error.code, "ERUNLIVE"); - // The first run is still the recorded one — nothing was overwritten. - const after = await request("GET", `/api/lanes/${lane.id}`); - assert.equal(after.body.lane.run_id, handle.id); + // Start a run for the lane + const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" }); + assert.equal(started.status, 200); + const runId = started.body.lane.run_id; + assert.equal(runs.getRun(runId).status, "running"); - // A start IS allowed again once that run is genuinely finished. - await request("POST", `/api/lanes/${lane.id}/stop`); - const deadline = Date.now() + 2000; - while (runs.getRun(handle.id).status === "spawning" && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)); + // Mock tmux so the session appears live + tmux.__setExecImpl(async (cmd, args) => { + if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) { + return { code: 0, stdout: "", stderr: "" }; + } + if (cmd === "tmux" && args[0] === "list-sessions") { + return { code: 0, stdout: "", stderr: "" }; + } + return { code: 1, stdout: "", stderr: "" }; + }); + + try { + // Try to start a second run — should be refused + const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" }); + assert.equal(second.status, 409); + assert.equal(second.body.error.code, "ERUNLIVE"); + // The first run is still the recorded one — nothing was overwritten. + const after = await request("GET", `/api/lanes/${lane.id}`); + assert.equal(after.body.lane.run_id, runId); + } finally { + tmux.__reset(); } + await request("DELETE", `/api/lanes/${lane.id}`); }); @@ -973,10 +975,10 @@ describe("destructive lane lifecycle actions", () => { assert.equal(typeof runId, "string"); runs.killRun(runId); const deadline = Date.now() + 2000; - while (!runs.getRun(runId).actualExitedAt && Date.now() < deadline) { + while (runs.getRun(runId).status !== "gone" && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 10)); } - assert.notEqual(runs.getRun(runId).actualExitedAt, null); + assert.equal(runs.getRun(runId).status, "gone"); } finally { process.env.PATH = originalPath; } @@ -1055,40 +1057,6 @@ describe("destructive lane lifecycle actions", () => { describe("lane ensure, start mode, lane_id and releasing a finished run", () => { const { db } = require("../db"); - /** - * Put a throwaway `claude` on PATH for the duration of one test. The script - * records its argv so a test can prove what the real spawn received. - */ - function withFakeClaude(name, scriptBody, fn) { - const bin = path.join(ROOT, `fake-claude-${name}`); - fs.mkdirSync(bin, { recursive: true }); - const argvLog = path.join(bin, "argv.json"); - fs.writeFileSync( - path.join(bin, "claude"), - "#!/usr/bin/env node\n" + - `require("node:fs").writeFileSync(${JSON.stringify(argvLog)}, JSON.stringify(process.argv.slice(2)));\n` + - scriptBody - ); - fs.chmodSync(path.join(bin, "claude"), 0o755); - const originalPath = process.env.PATH; - process.env.PATH = `${bin}${path.delimiter}${originalPath}`; - return Promise.resolve(fn({ argvLog })).finally(() => { - process.env.PATH = originalPath; - }); - } - - /** Poll until the lane no longer holds a run, then return it. */ - async function waitForRelease(id) { - const deadline = Date.now() + 7000; - let lane; - while (Date.now() < deadline) { - lane = (await request("GET", `/api/lanes/${id}`)).body.lane; - if (lane.run_id === null) return lane; - await new Promise((resolve) => setTimeout(resolve, 25)); - } - assert.fail(`lane ${id} still held run_id ${lane && lane.run_id} after 7 seconds`); - } - async function adoptedLane(name) { const cwd = path.join(ROOT, `ensure-${name}`); fs.mkdirSync(cwd, { recursive: true }); @@ -1161,139 +1129,38 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () => assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 0); }); - it("rejects an unknown start mode with 400 and spawns nothing", async () => { - const lane = await adoptedLane("bad-mode"); - const r = await request("POST", `/api/lanes/${lane.id}/start`, { - prompt: "hi", - mode: "telepathy", - }); - assert.equal(r.status, 400); - assert.equal(r.body.error.code, "EBADMODE"); - assert.equal((await request("GET", `/api/lanes/${lane.id}`)).body.lane.run_id, null); - }); - - it("passes mode headless through to the spawn and records lane_id in dashboard_runs", async () => { - const lane = await adoptedLane("headless-mode"); - await withFakeClaude("headless", "process.exit(0);\n", async ({ argvLog }) => { - const started = await request("POST", `/api/lanes/${lane.id}/start`, { - prompt: "one shot", - mode: "headless", - }); - assert.equal(started.status, 200, JSON.stringify(started.body)); - const runId = started.body.lane.run_id; - assert.equal(typeof runId, "string"); - - const row = db.prepare("SELECT mode, lane_id FROM dashboard_runs WHERE id = ?").get(runId); - assert.equal(row.mode, "headless"); - assert.equal(row.lane_id, lane.id); - - await waitForRelease(lane.id); - // The real child saw the headless argv shape: the prompt in argv via -p. - const argv = JSON.parse(fs.readFileSync(argvLog, "utf8")); - assert.equal(argv.includes("-p"), true); - assert.equal(argv[argv.indexOf("-p") + 1], "one shot"); - }); - }); - - it("filters GET /api/run/history by laneId", async () => { - const lane = await adoptedLane("history-filter"); - const other = await adoptedLane("history-filter-other"); - let laneRunId; - await withFakeClaude("history", "process.exit(0);\n", async () => { - const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "lane run" }); - laneRunId = started.body.lane.run_id; - await waitForRelease(lane.id); - }); - - const filtered = await request("GET", `/api/run/history?laneId=${lane.id}`); - assert.equal(filtered.status, 200); - assert.deepEqual( - filtered.body.items.map((it) => it.id), - [laneRunId] - ); - assert.equal(filtered.body.items[0].lane_id, lane.id); - - const empty = await request("GET", `/api/run/history?laneId=${other.id}`); - assert.deepEqual(empty.body.items, []); - }); - - it("releases the lane when the run exits on its own", async () => { - const lane = await adoptedLane("release-exit-zero"); - await withFakeClaude("exit-zero", "process.exit(0);\n", async () => { - const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "quick" }); - assert.equal(started.status, 200, JSON.stringify(started.body)); - assert.equal(started.body.lane.status, "running"); - const released = await waitForRelease(lane.id); - assert.equal(released.run_id, null); - assert.equal(released.status, "idle"); - assert.equal(runs.getRun(started.body.lane.run_id).status, "completed"); - }); - }); - - it("releases the lane when the run exits non-zero", async () => { - const lane = await adoptedLane("release-exit-three"); - await withFakeClaude("exit-three", "process.exit(3);\n", async () => { - const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "fails" }); - assert.equal(started.status, 200, JSON.stringify(started.body)); - const released = await waitForRelease(lane.id); - assert.equal(released.run_id, null); - assert.equal(released.status, "idle"); - assert.equal(runs.getRun(started.body.lane.run_id).status, "error"); - }); - }); - - it("releases the lane when the child never spawns at all", async () => { - const lane = await adoptedLane("release-spawn-error"); - const emptyBin = path.join(ROOT, "release-empty-bin"); - fs.mkdirSync(emptyBin, { recursive: true }); - const originalPath = process.env.PATH; - process.env.PATH = emptyBin; - try { - const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "no binary" }); - assert.equal(started.status, 200, JSON.stringify(started.body)); - const released = await waitForRelease(lane.id); - assert.equal(released.run_id, null); - assert.equal(released.status, "idle"); - assert.equal(runs.getRun(started.body.lane.run_id).status, "error"); - } finally { - process.env.PATH = originalPath; - } - }); - - it("releases the lane when a live run is killed", async () => { - const lane = await adoptedLane("release-killed"); - await withFakeClaude( - "killed", - "process.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n", - async () => { - const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "long" }); - assert.equal(started.status, 200, JSON.stringify(started.body)); - const runId = started.body.lane.run_id; - assert.equal(runs.killRun(runId), true); - const released = await waitForRelease(lane.id); - assert.equal(released.run_id, null); - assert.equal(released.status, "idle"); - assert.equal(runs.getRun(runId).status, "killed"); - } - ); - }); - - it("leaves a lane that already moved on to a different run alone", async () => { + it("leaves a lane that already has a live run_id untouched during healing", async () => { + const tmux = require("../lib/tmux"); const lane = await adoptedLane("release-moved-on"); - const child = makeRunChild({ exitsOnKill: true }); - const stale = runs.__injectChildForTest({ child }); - const live = runs.__injectChildForTest({ child: makeRunChild({ exitsOnKill: false }) }); - await request("PATCH", `/api/lanes/${lane.id}`, { run_id: live.id, status: "running" }); - // The stale run's exit must not clear the lane's CURRENT run. - runs.killRun(stale.id); - const deadline = Date.now() + 2000; - while (!runs.getRun(stale.id).actualExitedAt && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)); + // Create a run for this lane. + const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" }); + assert.equal(started.status, 200, JSON.stringify(started.body)); + const runId = started.body.lane.run_id; + + // Mock tmux so the run appears to be live. + tmux.__setExecImpl(async (cmd, args) => { + if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) { + return { code: 0, stdout: "", stderr: "" }; + } + if (cmd === "tmux" && args[0] === "list-sessions") { + return { code: 0, stdout: "", stderr: "" }; + } + return { code: 1, stdout: "", stderr: "" }; + }); + + try { + // Read the lane — it should NOT clear the run_id since it's still live. + const before = (await request("GET", `/api/lanes/${lane.id}`)).body.lane; + assert.equal(before.run_id, runId); + assert.equal(before.status, "running"); + + // Read again — same result, healing preserves live runs. + const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane; + assert.equal(after.run_id, runId); + assert.equal(after.status, "running"); + } finally { + tmux.__reset(); } - assert.notEqual(runs.getRun(stale.id).actualExitedAt, null); - const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane; - assert.equal(after.run_id, live.id); - assert.equal(after.status, "running"); }); }); diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index b89f27e..db34640 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -645,14 +645,12 @@ describe("lane actions", () => { await request("DELETE", `/api/lanes/${c.body.lane.id}`); }); - it("message on a lane with a recorded-but-not-live run returns 409", async () => { + it("message on a lane is no longer supported via REST", async () => { const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" }); const id = c.body.lane.id; - // Patch the lane with a bogus run_id (never existed, so not live). - await request("PATCH", `/api/lanes/${id}`, { run_id: "nonexistent-run" }); const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" }); - assert.equal(r.status, 409); - assert.equal(r.body.error.code, "ENORUN"); + assert.equal(r.status, 400); + assert.equal(r.body.error.code, "EUNSUPPORTED"); await request("DELETE", `/api/lanes/${id}`); }); }); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index c38278d..ec1c9ae 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -18,7 +18,7 @@ const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/ const laneFeatures = require("../lib/lane-features"); const proofLib = require("../lib/proof"); const { broadcast } = require("../websocket"); -const runs = require("../lib/run-spawner"); +const runs = require("../lib/pty-run"); const { sameOriginGuard } = require("./run"); const { preflight } = require("../lib/lane-preflight"); const { @@ -69,8 +69,28 @@ function lastEventAge(lane) { return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000)); } +/** + * Self-heals a stale `run_id`: a tmux-backed run has no exit event to push a + * release notification, so liveness is re-checked here, on every read, + * instead — the same "computed fact, never stored" principle this repo + * already applies to lane runtime up/down. A lane whose run_id points at a + * tmux session that's gone (the pane's process exited, or it was killed + * outside the dashboard entirely) gets released the next time anything reads + * it, exactly like the old push-based handler did, just pulled instead of + * pushed. + */ +function healRunId(lane) { + if (!lane.run_id) return lane; + const run = runs.getRun(lane.run_id); + if (run && run.status === "running") return lane; + lanesLib.updateLane(lane.id, { run_id: null, status: "idle" }); + broadcastLane(lane.id); + return lanesLib.getLane(lane.id); +} + function payload(lane) { - return lanesLib.lanePayload(lane, lastEventAge(lane)); + const healed = healRunId(lane); + return lanesLib.lanePayload(healed, lastEventAge(healed)); } /** A feature row's pipeline view, computed the same way payload() computes @@ -91,23 +111,6 @@ function broadcastLane(id) { if (lane) broadcast("lane_update", { lane: payload(lane) }); } -/** - * Release the lane holding a run that has just finished. Registered as a - * callback because the spawner must not require this router back: it is - * already required FROM here, and broadcastLane needs this file's payload(). - * - * No lane lock: the read, the guard and the write are one synchronous - * better-sqlite3 sequence with no `await` between them, so nothing can - * interleave. Matching run_id is what keeps a lane that has already moved on to - * a different run untouched. - */ -runs.setRunExitHandler(({ runId }) => { - const lane = lanesLib.listLanes().find((l) => l.run_id === runId); - if (!lane) return; - lanesLib.updateLane(lane.id, { run_id: null, status: "idle" }); - broadcastLane(lane.id); -}); - router.get("/", (_req, res) => { const lanes = lanesLib.listLanes().map(payload); res.json({ @@ -620,8 +623,6 @@ router.post("/worktree", sameOriginGuard, async (req, res) => { }); const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]); -// The modes the spawner accepts, same as POST /api/run. -const RUN_MODES = new Set(["headless", "conversation"]); const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]); const RUN_EXIT_POLL_MS = 50; // killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough @@ -665,7 +666,7 @@ function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** Kill a lane run and wait for the child's real `exit` event before touching its cwd. */ +/** Kill a lane run and wait for the tmux session to exit before touching its cwd. */ async function stopLaneRun(lane) { if (!lane.run_id) return; try { @@ -676,7 +677,7 @@ async function stopLaneRun(lane) { const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS; let run = runs.getRun(lane.run_id); - while (run && !run.actualExitedAt) { + while (run && run.status !== "gone") { if (Date.now() >= deadline) { throw lifecycleError( "ERUNTIMEOUT", @@ -975,7 +976,7 @@ router.post("/:id/sync-base", sameOriginGuard, async (req, res) => { /** * Lane control. Deliberately thin: every action maps onto one existing - * run-spawner call. There is no queue, no chaining, no gate evaluation — the + * lifecycle function. There is no queue, no chaining, no gate evaluation — the * dashboard drives a lane, it does not orchestrate a pipeline. */ router.post("/:id/:action", sameOriginGuard, async (req, res) => { @@ -1079,17 +1080,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { try { switch (action) { case "start": { - // Same two modes POST /api/run accepts. Unlike that route, an unknown - // value is refused rather than silently coerced to a conversation. - if (body.mode != null && !RUN_MODES.has(body.mode)) { - return res.status(400).json({ - error: { code: "EBADMODE", message: `mode must be one of: headless, conversation` }, - }); - } // Overwriting run_id while its child is alive orphans that child: a later // reset would kill and await only the RECORDED run, then `git clean -fd` - // the directory the orphan is still writing into — the exact hazard - // actualExitedAt exists to close. Stop the first run before starting a + // the directory the orphan is still writing into. Stop the first run before starting a // second. The check and the spawn happen under the per-lane lock so that // atomicity is guaranteed rather than an accident of this code having no // `await` between them — a future edit that adds one must not reopen the @@ -1101,13 +1094,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { // spawning a run for a lane that no longer exists. if (!current) return { missing: true }; const live = current.run_id ? runs.getRun(current.run_id) : null; - if (live && (live.status === "spawning" || live.status === "running")) { + if (live && live.status === "running") { return { conflict: true }; } const handle = runs.spawnRun({ - mode: body.mode || "conversation", laneId: current.id, - prompt: body.prompt || "", + initialPrompt: body.prompt || "", cwd: current.cwd, model: body.model, permissionMode: body.permissionMode, @@ -1140,23 +1132,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { break; } case "message": { - if (!lane.run_id) { - return res - .status(409) - .json({ error: { code: "ENORUN", message: "lane has no live run" } }); - } - // Check that the recorded run is actually live (spawning or running). - // If a run finished recently, its run_id is still recorded but sendInput - // would throw ENOTRUNNING. Return 409 so the client knows it's not a server error. - const run = runs.getRun(lane.run_id); - if (!run || (run.status !== "spawning" && run.status !== "running")) { - return res - .status(409) - .json({ error: { code: "ENORUN", message: "lane has no live run" } }); - } - runs.sendInput(lane.run_id, String(body.text || "")); - lanesLib.updateLane(lane.id, { needs_action: null }); - break; + return res.status(400).json({ + error: { + code: "EUNSUPPORTED", + message: + "sending input to a lane's run is no longer supported via REST — open the lane's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)", + }, + }); } case "clear": lanesLib.clearLane(lane.id); From f1e7d4245abeb9f38682a78d19687626d51088c7 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 11:08:21 +0700 Subject: [PATCH 11/18] test(lanes): add test for stale run_id clearing during healing Add missing test coverage for healRunId's core behavior: that a STALE run_id (tmux session gone) gets CLEARED to null with status: idle when read via GET. The existing test only verified the LIVE case (session still running). This test proves the release-on-gone path, simulating a session death via tmux mock. --- server/__tests__/lane-lifecycle.test.js | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index d2d0053..d4403c2 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -1163,4 +1163,38 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () => tmux.__reset(); } }); + + it("clears a stale run_id and sets status to idle when the tmux session is gone", async () => { + const tmux = require("../lib/tmux"); + const lane = await adoptedLane("release-stale-run"); + + // Start a run for this lane. + const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" }); + assert.equal(started.status, 200, JSON.stringify(started.body)); + const runId = started.body.lane.run_id; + assert.equal(typeof runId, "string"); + assert.equal(started.body.lane.status, "running"); + + // Mock tmux so the session appears to be gone (has-session fails with status 1). + tmux.__setExecImpl((args) => { + if (args[0] === "has-session" && args[1] === "-t" && args[2] === runId) { + const e = new Error("no such session"); + e.status = 1; + throw e; + } + if (args[0] === "list-sessions") { + return ""; + } + return ""; + }); + + try { + // Read the lane — it should clear the run_id and set status to idle. + const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane; + assert.equal(after.run_id, null, "run_id should be cleared for stale session"); + assert.equal(after.status, "idle", "status should be idle after run is gone"); + } finally { + tmux.__reset(); + } + }); }); From 2f39f4ec98b0910a0aefdf654b4bc8b66a7ab3bd Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 11:58:38 +0700 Subject: [PATCH 12/18] feat(run): wire Workspace to TerminalView, delete the stream-json Run feature Combines three tasks that couldn't land as separate commits: the pre-commit hook's full test run crashes on any intermediate state where Workspace.tsx still imports the files being deleted, so the deletion (old RunConsole/useRunStream/run-spawner/stream-json-parser), the RunSetup/RunHistory type adjustments, and this file's own TerminalView wiring had to be staged together and committed as one hook-passable unit. - Delete RunConsole.tsx, useRunStream.ts, server/lib/run-spawner.js, server/lib/stream-json-parser.js and their tests (Task 8). - Adjust RunSetup.tsx/RunHistory.tsx to the tmux-backed RunHandle/ RunStartArgs/DashboardRunHistoryItem shapes, remove mode selection UI (Task 9). - Swap Workspace.tsx's chat-bubble run console for TerminalView (xterm.js over /ws-pty/:runId), drop the stream-json envelope plumbing, update Start/Resume to the new RunStartArgs payload. Create onStartFromSetup handler to work with RunSetup's new callback shape. Remove mode state and related plumbing. Remove send/followUp state (no longer using old RunConsole chat interface). - Add promptPlaceholderTerminal i18n key to support RunSetup's new placeholder text (Task 10). - Update Workspace.test.tsx to mock TerminalView component. - Regenerate screens.snapshot.test.tsx snapshot (only Workspace run panel changes: terminal container instead of chat bubbles). --- client/src/components/run/RunConsole.tsx | 1097 ----------------- client/src/components/run/RunHistory.tsx | 68 +- client/src/components/run/RunSetup.tsx | 162 +-- .../run/__tests__/RunConsole.test.tsx | 172 --- .../run/__tests__/RunHistory.test.tsx | 31 +- .../run/__tests__/RunSetup.test.tsx | 23 +- .../src/hooks/__tests__/useRunStream.test.tsx | 174 --- client/src/hooks/useRunStream.ts | 489 -------- client/src/i18n/locales/en/run.json | 3 +- client/src/i18n/locales/vi/run.json | 3 +- client/src/pages/Workspace.tsx | 301 ++--- client/src/pages/__tests__/Workspace.test.tsx | 6 + .../screens.snapshot.test.tsx.snap | 700 +---------- server/__tests__/stream-json-parser.test.js | 141 --- server/lib/run-spawner.js | 567 --------- server/lib/stream-json-parser.js | 40 - 16 files changed, 266 insertions(+), 3711 deletions(-) delete mode 100644 client/src/components/run/RunConsole.tsx delete mode 100644 client/src/components/run/__tests__/RunConsole.test.tsx delete mode 100644 client/src/hooks/__tests__/useRunStream.test.tsx delete mode 100644 client/src/hooks/useRunStream.ts delete mode 100644 server/__tests__/stream-json-parser.test.js delete mode 100644 server/lib/run-spawner.js delete mode 100644 server/lib/stream-json-parser.js diff --git a/client/src/components/run/RunConsole.tsx b/client/src/components/run/RunConsole.tsx deleted file mode 100644 index e8f5342..0000000 --- a/client/src/components/run/RunConsole.tsx +++ /dev/null @@ -1,1097 +0,0 @@ -/** - * @file RunConsole.tsx - * @description The run console: everything that renders one run's live - * conversation and drives its next turn. Moved verbatim out of `pages/Run.tsx` - * (where it was `RunSession`) so the Run page and the Workspace page can both - * mount the same console. - * - * Three pieces live here: - * - the envelope stream — user turns, assistant markdown, thinking, tool - * uses and tool results, plus the result footer; - * - the token / context-window meter rolled up from the envelope log; - * - the prompt editor with its `/` slash-command and `@` file autocomplete. - * - * Props only: no API call except the `@`-file lookup the editor already owned, - * and no stream subscription — `envelopes` arrives as a prop, so the page keeps - * `useRunStream` and both pages share one subscription per run. - * - * @author Nguyễn Ngọc Trí Vĩ - */ - -import { useEffect, useMemo, useRef, useState } from "react"; -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { - Play, - Square, - Send, - RefreshCw, - Sparkles, - Terminal, - CheckCircle2, - XCircle, - Clock, - ExternalLink, - Plus, - AtSign, - Slash as SlashIcon, - FileCode, -} from "lucide-react"; -import { api } from "../../lib/api"; -import type { RunHandle, RunMode } from "../../lib/api"; -import { MarkdownContent } from "../conversation/MarkdownContent"; -import type { - AssistantMessage, - ContentBlock, - Envelope, - ResultEnvelope, - SystemInit, - UserMessage, -} from "../../hooks/useRunStream"; - -// ── Token / context-window meter ────────────────────────────────────── - -interface TokenStats { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - costUsd: number | null; - contextWindow: number | null; -} - -const DEFAULT_CONTEXT_WINDOW = 200_000; - -/** - * Roll up token usage from the in-memory envelope log. Pulls the latest - * `usage` block from `stream_event/message_delta` events (live numbers - * during streaming) and the canonical `result.usage` envelope when the run - * finishes. The 1M-context Opus variants emit `contextWindow` in - * `result.modelUsage`; we surface that to size the meter correctly. - */ -function computeTokens(envelopes: Envelope[]): TokenStats { - // Per-turn rolling counters (overwritten as each new turn's message_start - // arrives). The latest message_start's input + cache numbers reflect the - // current turn's prompt size, which is the right thing to show in the - // "Context" gauge. - let inputTokens = 0; - let cacheReadTokens = 0; - let cacheCreationTokens = 0; - // Output is summed across all completed turns plus the running current - // turn - claude reports output_tokens as a per-turn (per-message) number, - // not cumulative. Without summing, the meter resets every time a new - // `message_start` arrives. - let completedOutputTokens = 0; - let currentTurnOutput = 0; - let costUsd: number | null = null; - let contextWindow: number | null = null; - let sawMessageStart = false; - // While we don't have an authoritative output count from message_delta / - // result, estimate from the char count in the streaming assistant block - // so the meter ticks live as text appears (claude doesn't emit usage on - // every text_delta). - let outputAuthoritativeForCurrent = false; - let streamingChars = 0; - - const commitTurn = () => { - completedOutputTokens += currentTurnOutput; - currentTurnOutput = 0; - outputAuthoritativeForCurrent = false; - streamingChars = 0; - }; - - for (const env of envelopes) { - const e = env as { type?: string }; - if (e.type === "stream_event") { - const ev = ( - env as { - event?: { - type?: string; - usage?: Record; - message?: { usage?: Record }; - }; - } - ).event; - if (!ev) continue; - if (ev.type === "message_start") { - // Roll the previous turn's running output into the cumulative total - // before resetting for this new turn. - if (sawMessageStart) commitTurn(); - sawMessageStart = true; - const u = ev.message?.usage; - if (u) { - inputTokens = u.input_tokens ?? 0; - cacheReadTokens = u.cache_read_input_tokens ?? 0; - cacheCreationTokens = u.cache_creation_input_tokens ?? 0; - currentTurnOutput = u.output_tokens ?? 0; - } - } else if (ev.type === "message_delta") { - const u = ev.usage; - if (u && typeof u.output_tokens === "number") { - // Authoritative running output for the current turn. - currentTurnOutput = u.output_tokens; - outputAuthoritativeForCurrent = true; - } - } - } else if (e.type === "result") { - const r = env as ResultEnvelope & { - modelUsage?: Record< - string, - { - contextWindow?: number; - inputTokens?: number; - outputTokens?: number; - cacheReadInputTokens?: number; - cacheCreationInputTokens?: number; - } - >; - }; - // Result is end-of-run: commit any in-flight current turn first. - if (currentTurnOutput > 0) { - completedOutputTokens += currentTurnOutput; - currentTurnOutput = 0; - outputAuthoritativeForCurrent = false; - } - if (typeof r.total_cost_usd === "number") costUsd = r.total_cost_usd; - if (r.modelUsage && typeof r.modelUsage === "object") { - for (const m of Object.values(r.modelUsage)) { - if (!m || typeof m !== "object") continue; - if (typeof m.contextWindow === "number") contextWindow = m.contextWindow; - // Prefer modelUsage's per-model totals when available - these are - // the canonical per-run numbers. - if (typeof m.inputTokens === "number") inputTokens = m.inputTokens; - if (typeof m.cacheReadInputTokens === "number") cacheReadTokens = m.cacheReadInputTokens; - if (typeof m.cacheCreationInputTokens === "number") - cacheCreationTokens = m.cacheCreationInputTokens; - if (typeof m.outputTokens === "number") { - // modelUsage.outputTokens is the run total for this model - use - // it as the canonical cumulative output, replacing our running - // sum. - completedOutputTokens = m.outputTokens; - } - } - } - } else if (e.type === "system" && (env as SystemInit).model) { - // Heuristic: 1M Opus has [1m] in the model id - const model = (env as SystemInit).model || ""; - if (/\[1m\]/i.test(model)) contextWindow = 1_000_000; - } else if (e.type === "assistant") { - const msg = ( - env as { - message?: { - _streaming?: boolean; - content?: ContentBlock[]; - usage?: { - input_tokens?: number; - output_tokens?: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }; - }; - } - ).message; - if (msg?._streaming) { - streamingChars = 0; - const blocks = msg.content || []; - for (const b of blocks) { - if (b.type === "text") { - streamingChars += ((b as { text?: string }).text || "").length; - } else if (b.type === "thinking") { - streamingChars += ((b as { thinking?: string }).thinking || "").length; - } - } - } else if (msg?.usage) { - // Transcript-derived seed envelopes carry usage but have no - // `message.id` (transcriptToEnvelopes doesn't set one). Live-stream - // canonical envelopes always have an id assigned by message_start, - // and their tokens are already counted via stream_event / commitTurn - // - folding them here would double-count. Use id-presence as the - // discriminator: no id → transcript-seeded → fold; id → live → skip. - const hasId = !!(msg as { id?: string }).id; - if (!hasId) { - const u = msg.usage; - if (typeof u.input_tokens === "number") inputTokens = u.input_tokens; - if (typeof u.cache_read_input_tokens === "number") { - cacheReadTokens = u.cache_read_input_tokens; - } - if (typeof u.cache_creation_input_tokens === "number") { - cacheCreationTokens = u.cache_creation_input_tokens; - } - if (typeof u.output_tokens === "number") { - completedOutputTokens += u.output_tokens; - } - } - } - } - } - - // While we don't have an authoritative output count for the current turn, - // surface the char-based estimate so the meter ticks live during streaming. - if (!outputAuthoritativeForCurrent && streamingChars > 0) { - const estimate = Math.ceil(streamingChars / 4); - if (estimate > currentTurnOutput) currentTurnOutput = estimate; - } - - return { - inputTokens, - outputTokens: completedOutputTokens + currentTurnOutput, - cacheReadTokens, - cacheCreationTokens, - costUsd, - contextWindow, - }; -} - -function formatNum(n: number): string { - if (n < 1000) return String(n); - if (n < 100_000) return (n / 1000).toFixed(1) + "k"; - if (n < 1_000_000) return Math.round(n / 1000) + "k"; - return (n / 1_000_000).toFixed(2) + "M"; -} - -function TokenMeter({ stats }: { stats: TokenStats }) { - const { t } = useTranslation("run"); - const total = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens; - const cap = stats.contextWindow ?? DEFAULT_CONTEXT_WINDOW; - const pct = Math.min(100, Math.round((total / cap) * 100)); - // Colour is the whole warning mechanism here - the meter is one status line, - // so there is no room for a bar plus five labelled figures. - const tone = - pct >= 95 ? "text-status-danger" : pct >= 80 ? "text-status-warning" : "text-fg-secondary"; - return ( -
- - ── - - {`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`} - ↑{formatNum(stats.outputTokens)} - {stats.cacheReadTokens > 0 && ( - - ⚡{formatNum(stats.cacheReadTokens)} - - )} - {stats.costUsd != null && ( - ${stats.costUsd.toFixed(4)} - )} -
- ); -} - -// ── Slash commands (built-in list + user/project/plugin from API) ───── - -export interface SlashCommand { - name: string; - description?: string; - source: "builtin" | "user" | "project" | "plugin"; - filePath?: string; -} - -// Built-in commands the CLI handles itself. We surface them in autocomplete -// with a "CLI only" tag so users know they won't actually execute when -// sent over stream-json stdin. -export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [ - { name: "help", description: "List available commands", source: "builtin" }, - { name: "clear", description: "Clear the conversation", source: "builtin" }, - { name: "config", description: "Open the interactive config menu", source: "builtin" }, - { name: "model", description: "Change model mid-session", source: "builtin" }, - { name: "compact", description: "Compact the conversation context", source: "builtin" }, - { name: "memory", description: "Edit CLAUDE.md", source: "builtin" }, - { name: "hooks", description: "Manage hooks", source: "builtin" }, - { name: "cost", description: "Show session cost", source: "builtin" }, - { name: "agents", description: "List subagents", source: "builtin" }, - { name: "review", description: "Review current changes", source: "builtin" }, - { name: "release-notes", description: "Show CC release notes", source: "builtin" }, - { name: "permissions", description: "Edit permission rules", source: "builtin" }, - { name: "status", description: "Show session status", source: "builtin" }, - { name: "init", description: "Initialise CLAUDE.md from codebase", source: "builtin" }, - { name: "login", description: "Sign in to Claude", source: "builtin" }, - { name: "logout", description: "Sign out", source: "builtin" }, - { name: "exit", description: "Exit the session", source: "builtin" }, - { name: "mcp", description: "Manage MCP servers", source: "builtin" }, - { name: "plugin", description: "Manage plugins", source: "builtin" }, - { name: "output-style", description: "Change output style", source: "builtin" }, -]; - -function commandSourceLabel(s: SlashCommand["source"]): string { - return s === "builtin" - ? "CLI only" - : s === "user" - ? "user" - : s === "project" - ? "project" - : "plugin"; -} - -function commandSourceTone(s: SlashCommand["source"]): string { - return s === "builtin" - ? "bg-surface-4/10 text-fg-secondary border-border-light/30" - : s === "user" - ? "bg-sky-500/10 text-sky-300 border-sky-500/30" - : s === "project" - ? "bg-status-success/10 text-status-success border-status-success/30" - : "bg-violet-500/10 text-violet-300 border-violet-500/30"; -} - -// ── Autocomplete dropdown for slash + @-files ───────────────────────── - -interface AutocompleteState { - kind: "slash" | "file"; - query: string; - // The position in the textarea where the trigger character starts (so we - // can replace from there to the cursor on selection). - triggerStart: number; - cursor: number; -} - -/** - * Tiered slash-command match scoring. Higher = more relevant. Returns 0 for - * "doesn't match, hide it." Tiers in descending priority: - * 1. Exact name match - * 2. Name starts with query - * 3. Word boundary (after `-` / `_` / `.`) starts with query - * 4. Name contains query (earlier index ranks higher) - * 5. Subsequence match across the name - * 6. Description contains query - only when query is at least 3 chars, - * so a single keystroke can't drag in tangential descriptions. - */ -function scoreSlashMatch(name: string, description: string | undefined, q: string): number { - if (!q) return 1; - const n = name.toLowerCase(); - if (n === q) return 1000; - if (n.startsWith(q)) return 800 - Math.min(n.length, 100); - const parts = n.split(/[-_.\s]/); - if (parts.some((p) => p.startsWith(q))) { - return 600 - Math.min(n.length, 100); - } - const idx = n.indexOf(q); - if (idx >= 0) return 400 - Math.min(idx, 100); - if (subsequenceMatch(n, q)) return 200; - if (q.length >= 3) { - const d = (description || "").toLowerCase(); - if (d.includes(q)) return 100; - } - return 0; -} - -function subsequenceMatch(s: string, q: string): boolean { - let i = 0; - for (let k = 0; k < s.length && i < q.length; k++) { - if (s[k] === q[i]) i++; - } - return i === q.length; -} - -function detectAutocomplete(value: string, cursor: number): AutocompleteState | null { - // Look back from the cursor to find the active "token". A token starts at - // the beginning of the line / after whitespace and continues until cursor. - let start = cursor; - while (start > 0) { - const ch = value[start - 1]; - if (!ch || /\s/.test(ch)) break; - start--; - } - const tok = value.slice(start, cursor); - if (tok.startsWith("/") && tok.length >= 1) { - // Only trigger for slash if it's at line start OR right after whitespace. - // The detection above already enforces that. - return { kind: "slash", query: tok.slice(1), triggerStart: start, cursor }; - } - if (tok.startsWith("@") && tok.length >= 1) { - return { kind: "file", query: tok.slice(1), triggerStart: start, cursor }; - } - return null; -} - -interface PromptEditorProps { - value: string; - onChange: (s: string) => void; - onSubmit?: () => void; - placeholder?: string; - rows?: number; - slashCommands: SlashCommand[]; - fileCwd: string; - autoFocus?: boolean; -} - -export function PromptEditor({ - value, - onChange, - onSubmit, - placeholder, - rows = 4, - slashCommands, - fileCwd, - autoFocus, -}: PromptEditorProps) { - const { t } = useTranslation("run"); - const taRef = useRef(null); - const [state, setState] = useState(null); - const [active, setActive] = useState(0); - const [fileSuggestions, setFileSuggestions] = useState([]); - const fileFetchRef = useRef<{ q: string; t: number } | null>(null); - - // Slash filter - tiered scoring so prefix matches outrank arbitrary - // substring hits, name matches outrank description matches, and shorter - // names break ties when scores are equal. - const slashItems = useMemo(() => { - if (!state || state.kind !== "slash") return [] as SlashCommand[]; - const q = state.query.toLowerCase(); - const sourceOrder = { project: 0, user: 1, plugin: 2, builtin: 3 } as const; - if (!q) { - return [...slashCommands].sort( - (a, b) => sourceOrder[a.source] - sourceOrder[b.source] || a.name.localeCompare(b.name) - ); - } - type Scored = { cmd: SlashCommand; score: number }; - const scored: Scored[] = []; - for (const cmd of slashCommands) { - const score = scoreSlashMatch(cmd.name, cmd.description, q); - if (score > 0) scored.push({ cmd, score }); - } - return scored - .sort( - (a, b) => - b.score - a.score || - sourceOrder[a.cmd.source] - sourceOrder[b.cmd.source] || - a.cmd.name.length - b.cmd.name.length || - a.cmd.name.localeCompare(b.cmd.name) - ) - .map((s) => s.cmd); - }, [state, slashCommands]); - - // File fetch (debounced) - useEffect(() => { - if (!state || state.kind !== "file") return; - const ts = Date.now(); - fileFetchRef.current = { q: state.query, t: ts }; - const tid = setTimeout(() => { - if (fileFetchRef.current?.t !== ts) return; - api.run - .files(fileCwd, state.query) - .then((r) => setFileSuggestions(r.items)) - .catch(() => setFileSuggestions([])); - }, 120); - return () => clearTimeout(tid); - }, [state, fileCwd]); - - const items = state?.kind === "file" ? fileSuggestions : slashItems; - - useEffect(() => { - if (active >= items.length) setActive(Math.max(0, items.length - 1)); - }, [items.length, active]); - - const insertChoice = (choice: SlashCommand | string) => { - if (!state || !taRef.current) return; - const ta = taRef.current; - const before = value.slice(0, state.triggerStart); - const after = value.slice(state.cursor); - let inserted: string; - if (state.kind === "slash") { - const c = choice as SlashCommand; - inserted = `/${c.name}`; - } else { - inserted = `@${choice as string}`; - } - const next = before + inserted + (after.startsWith(" ") || after === "" ? "" : " ") + after; - onChange(next); - setState(null); - setActive(0); - // Re-position cursor after the inserted token + a trailing space - requestAnimationFrame(() => { - const pos = before.length + inserted.length + 1; - ta.focus(); - ta.setSelectionRange(pos, pos); - }); - }; - - const onKeyDown = (e: React.KeyboardEvent) => { - if (state && items.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setActive((a) => Math.min(items.length - 1, a + 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setActive((a) => Math.max(0, a - 1)); - return; - } - if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - const choice = items[active]; - if (choice) insertChoice(choice); - return; - } - if (e.key === "Tab") { - e.preventDefault(); - const choice = items[active]; - if (choice) insertChoice(choice); - return; - } - if (e.key === "Escape") { - e.preventDefault(); - setState(null); - return; - } - } - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - e.preventDefault(); - onSubmit?.(); - } - }; - - const onTextareaInput = (e: React.ChangeEvent) => { - onChange(e.target.value); - const ta = e.target; - const next = detectAutocomplete(ta.value, ta.selectionStart || 0); - setState(next); - if (!next) setActive(0); - }; - - const onSelect = (e: React.SyntheticEvent) => { - const ta = e.currentTarget; - const next = detectAutocomplete(ta.value, ta.selectionStart || 0); - setState(next); - }; - - return ( -
-