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.
78 KiB
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-<id>), 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/:runIdpath must reuse the exact sameverifyClientauth (Host allowlist +DASHBOARD_TOKEN) as the existing/wspath (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-ptyinvocations useexecFile/node-pty.spawnwith an explicit argument array — never a concatenated shell string (matches this repo's existing rule for git inserver/lib/worktree.js,CLAUDE.md). - Server tests must not exec real tmux (no CI has it installed) — mock the
tmux.jsmodule via an injectable exec seam, same style asrun-spawner.js's existing__injectChildForTest/__resettest seams. - Every new/modified
.js/.ts/.tsxfile keeps this repo's required file header (file overview +@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>—.claude/skills/file-headers/). npm run test:serverandnpm run test:clientmust stay green throughout; regeneratescreens.snapshot.test.tsxonly after reviewing the diff (repo testing policy).
Task 1: Add dependencies, Docker tmux, and the DB migration
Files:
- Modify:
package.json(root — addnode-pty) - Modify:
client/package.json(add@xterm/xterm,@xterm/addon-fit) - Modify:
Dockerfile:35-47(stage 3 — installtmux) - Modify:
server/db.js:248-262(schema),server/db.js:497area (migration probe)
Interfaces:
-
Produces:
dashboard_runs.tmux_session TEXTcolumn, available to every later server task. -
Step 1: Add server dependency
npm install node-pty@^1.0.0
- Step 2: Add client dependencies
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):
# ── 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_sessioncolumn and drop the now-unusedmodeNOT NULL requirement
In server/db.js, find the dashboard_runs table definition (around line 248) and change:
CREATE TABLE IF NOT EXISTS dashboard_runs (
id TEXT PRIMARY KEY,
session_id TEXT,
mode TEXT NOT NULL,
cwd TEXT NOT NULL,
to:
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):
// 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
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
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): booleannewSession({name, cwd, argv}: {name: string, cwd: string, argv: string[]}): void—argvis 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 gonelistSessions(prefix: string): string[]— session names starting withprefixisTmuxAvailable(): boolean__setExecImpl(fn)/__reset()— test seam, same pattern asrun-spawner.js's__injectChildForTest/__reset
-
Step 1: Write the failing tests
// 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ĩ <vinnt@smartgift.vn>
*/
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
node --test server/__tests__/tmux.test.js
Expected: FAIL — Cannot find module '../lib/tmux'.
- Step 3: Implement
server/lib/tmux.js
/**
* @file tmux.js
* @description Thin wrapper around the `tmux` CLI for the terminal-run
* feature. Every dashboard-managed session is named `ccam-lane-<id>` (see
* `pty-run.js`) so a real terminal can attach to the exact same session with
* `tmux attach -t ccam-lane-<id>` (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ĩ <vinnt@smartgift.vn>
*/
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
node --test server/__tests__/tmux.test.js
Expected: PASS, all 7 tests.
- Step 5: Commit
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'shasSession,newSession,killSession,listSessions(Task 2);dashboard-runs.js'srecordRun/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, ...}—idis the tmux session name.killRun(id: string): booleanlistRuns(): Array<PublicRun>— computed fresh fromtmux.listSessions("ccam-lane-")each call, no cached Map.getRun(id: string): PublicRun | nulllaneIdFromRunId(id: string): number | null— parsesccam-lane-<id>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
// 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ĩ <vinnt@smartgift.vn>
*/
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-<id> 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
node --test server/__tests__/pty-run.test.js
Expected: FAIL — Cannot find module '../lib/pty-run'.
- Step 3: Implement
server/lib/pty-run.js
/**
* @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-<laneId>` so a real terminal can attach
* to the exact same session (`tmux attach -t ccam-lane-<id>`, 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ĩ <vinnt@smartgift.vn>
*/
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
node --test server/__tests__/pty-run.test.js
Expected: PASS, all 7 tests.
- Step 5: Commit
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(rewritePOST /,GET /,DELETE /:id; removePOST /:id/message; keepGET /cwds,GET /files,sameOriginGuard,sanitiseCwdunchanged) - Modify:
server/routes/run.js— renameGET /binaryto also report tmux, or addGET /tmux(see Step 3) - Test:
server/__tests__/run.test.js(rewrite the spawn/kill/list cases; delete the/messagecases)
Interfaces:
-
Consumes:
pty-run.js'sspawnRun,killRun,getRun,listRuns(Task 3);tmux.js'sisTmuxAvailable(Task 2). -
Produces:
POST /api/runnow requireslaneId(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:
const runs = require("../lib/run-spawner");
with:
const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
- Step 2: Replace
GET /,POST /, andDELETE /:id
Replace the block from router.get("/", ...) (line 98) through the end of router.post("/", ...) (line 310) with:
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:
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:
router.get("/tmux", (_req, res) => {
res.json({ available: tmux.isTmuxAvailable() });
});
- Step 4: Update
GET /historyto stop cross-referencing the deleted in-memory Map
In the router.get("/history", ...) handler (line 115), replace the "cross-reference with live handles" block:
// 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:
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:
* @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:
// 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ĩ <vinnt@smartgift.vn>
*/
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
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
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(thenode-ptyattach logic, separated fromwebsocket.jsso it stays testable without a real socket) - Modify:
server/websocket.js(add the secondWebSocketServer) - Test:
server/__tests__/pty-attach.test.js
Interfaces:
-
Consumes:
pty-run.js'slaneIdFromRunId(Task 3, for validatingrunIdbefore touching tmux);node-pty'sspawn. -
Produces:
initPtyWebSocket(server)(called fromserver/index.jsnext to the existinginitWebSocket(server)), exported fromserver/websocket.js. -
Step 1: Write the failing test for the attach helper
// 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ĩ <vinnt@smartgift.vn>
*/
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-<digits>", () => {
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
node --test server/__tests__/pty-attach.test.js
Expected: FAIL — Cannot find module '../lib/pty-attach'.
- Step 3: Implement
server/lib/pty-attach.js
/**
* @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ĩ <vinnt@smartgift.vn>
*/
const RUN_ID_RE = /^ccam-lane-\d+$/;
/**
* Reject anything that isn't exactly `ccam-lane-<digits>` 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 <runId>` 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
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):
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:
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
initPtyWebSocketinserver/index.js
Find initWebSocket(server); (line 152) and add directly below it:
initWebSocket(server);
require("./websocket").initPtyWebSocket(server);
- Step 7: Manual smoke check (real tmux, real node-pty — not part of the automated suite)
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
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'sTerminal,@xterm/addon-fit'sFitAddon. -
Produces:
<TerminalView runId={string} wsBaseUrl={string} />— a self-contained component; Task 9's Workspace wiring is the only caller. -
Step 1: Write the failing test
// 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ĩ <vinnt@smartgift.vn>
*/
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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0];
ws.onopen?.();
ws.onmessage?.({ data: "hello" });
expect(writeMock).toHaveBeenCalledWith("hello");
});
it("forwards terminal keystrokes as outgoing WS sends", () => {
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0];
onDataHandlers[0]("ls -la\r");
expect(ws.sent).toEqual(["ls -la\r"]);
});
});
- Step 2: Run to verify it fails
cd client && npx vitest run src/components/run/__tests__/TerminalView.test.tsx
Expected: FAIL — Failed to resolve import "../TerminalView".
- Step 3: Implement
TerminalView.tsx
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<HTMLDivElement>(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 <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
}
- Step 4: Run the tests to verify they pass
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
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(therunobject) - Modify:
client/src/lib/types.ts:1279-1327(deleteRunStreamPayload,RunInputAckPayload; keep/trimRunStatusPayload— 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'sRunSetup/RunHistoryedits and Task 9'sWorkspacewiring 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:
/** 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-<laneId>`). */
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:
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.runobject
Replace the whole block from run: { (line 1476) through its closing }, (before the next top-level key) with:
run: {
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
list: () => request<RunListResponse>("/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<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
/** GET /api/run/:id - one run's current handle. */
get: (id: string) => request<RunHandle>(`/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:
// ───── 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-<laneId>`). */
id: string;
status: "running" | "gone";
/** Epoch-ms timestamp of this transition. */
at: number;
}
- Step 4: Type-check the client
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
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 actualrm, 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.jshas no caller outside the deleted spawner
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
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
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
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
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'sonSubmitnow calls the caller with aRunStartArgs-shaped object (no moremode);RunHistory'sActiveRunsSwitcher/RunsModalrenderRunHandle/DashboardRunHistoryItemwithout a mode badge. -
Step 1: Read
RunSetup.tsx's current mode-toggle UI and submit handler
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:
- The
modefield inRunSetupProps/local state (useState<"headless" | "conversation">). - The toggle UI (likely a
<Seg>pair — this file's ownSeghelper component, used elsewhere for the permission-mode/effort pickers). - Wherever the submit handler builds the
RunStartArgsobject.
- Step 2: Remove the mode toggle and update the submit shape
Delete the mode useState and its <Seg> toggle UI block entirely. In the submit handler, replace whatever currently builds {prompt, mode, cwd, model, ...} with:
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
ModeBadgeandmode-based rendering inRunHistory.tsx
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
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
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
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 newapi.run/RunHandle/RunStartArgs(Task 7),RunSetup/ActiveRunsSwitcher(Task 9). -
Step 1: Locate every reference to the deleted pieces
grep -n "RunConsole\|useRunStream\|envelopes\b" client/src/pages/Workspace.tsx
- Step 2: Replace the import
// before
import { RunConsole } from "../components/run/RunConsole";
import { useRunStream } from "../hooks/useRunStream";
// after
import { TerminalView } from "../components/run/TerminalView";
- Step 3: Remove the
useRunStreamcall 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
<RunConsole ... />render with<TerminalView />
// before (shape approximate — match whatever props RunConsole actually received)
<RunConsole
handle={runHandle}
envelopes={displayEnvelopes}
onSend={(text) => api.run.send(runHandle.id, text)}
onKill={() => api.run.kill(runHandle.id)}
/>
// after
{runHandle && (
<TerminalView runId={runHandle.id} wsBaseUrl={wsBaseUrl} />
)}
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
RunStartArgsshape
Wherever Workspace.tsx calls api.run.start(...) (around lines 205-761 per the earlier research), update the payload to the Task 7 shape:
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
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 <div data-testid="terminal-view" />, 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
cd client && npx tsc --noEmit 2>&1 | head -60
npx vitest run 2>&1 | tail -80
Once everything but the snapshot passes:
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
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 + newcmdLanesShellfunction)
Interfaces:
-
Consumes:
resolveLaneArg(existing helper, same onecmdLanesPipeline/cmdStageuse),get/patchHTTP helpers (existing),node:child_process'sspawn(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):
if (rest[0] === "shell") return cmdLanesShell(rest.slice(1));
- Step 2: Implement
cmdLanesShell
Add this function near cmdLanesPipeline (same file, same section):
/**
* 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)
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-<id>; 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 --helpanddocs/LANES.md's command reference
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
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(mentionccam lanes shellnext to the existingccam lanes pipelinedocumentation added in the prior session's work)
Interfaces: none — documentation only.
- Step 1: Find every doc section describing the old Run feature
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:
- **Run Claude from the browser.** Spawn a session in a lane's directory, stream
its output, send follow-ups, resume any past session.
with:
- **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/runsection
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 shelltodocs/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:
`ccam lanes shell` attaches a real terminal to the exact tmux session the
dashboard's Start/Resume buttons use for this lane (`ccam-lane-<id>`),
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
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
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
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
npm run test:client 2>&1 | tail -60
Expected: all green, including the regenerated snapshot from Task 10.
- Step 3: Client build and typecheck
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 .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:
npm run dev, open Workspace for an adopted lane.- Click Start — confirm the real Claude Code TUI renders in the browser via
TerminalView. - Type in the browser terminal — confirm input reaches the pane.
- In a real terminal, run
ccam lanes shellfor the same lane — confirm it drops into the exact same live session (typed output appears in both places). - Click Kill on the dashboard — confirm the tmux session ends and the real terminal's attach also exits.
- Confirm
GET /api/runno longer lists that run once killed.
- Step 6: Final commit (only if Steps 1-5 turned up fixes)
git add -A
git commit -m "fix: address issues found in full-suite verification"