Files
Claude-Code-Monitor/docs/superpowers/plans/2026-08-12-tmux-terminal-run.md
nntrivi2001 00f6338d4c docs: bring plan and spec into the tmux-terminal-run worktree
These were committed on main's local history but this worktree
branched from origin/main, which doesn't have them yet — copying the
files in so subagent-driven-development has a plan to read from this
branch.
2026-08-12 09:25:34 +07:00

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/: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ĩ <vinnt@smartgift.vn>.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

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_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:

  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): boolean
    • newSession({name, cwd, argv}: {name: string, cwd: string, argv: string[]}): voidargv 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

// 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'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<PublicRun> — 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-<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 (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:

const runs = require("../lib/run-spawner");

with:

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:

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 /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:

  // 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 (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

// 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 initPtyWebSocket in server/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's Terminal, @xterm/addon-fit's FitAddon.

  • 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 (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:

/** 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.run object

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

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'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

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 <Seg> 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 <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 ModeBadge and mode-based rendering in RunHistory.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 new api.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 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 <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 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:

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 + 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):

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 --help and docs/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 (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
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/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:

`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:

  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)
git add -A
git commit -m "fix: address issues found in full-suite verification"