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

```bash
npm install node-pty@^1.0.0
```

- [ ] **Step 2: Add client dependencies**

```bash
cd client && npm install @xterm/xterm@^5.5.0 @xterm/addon-fit@^0.10.0 && cd ..
```

- [ ] **Step 3: Install tmux in the Docker runtime stage**

Edit `Dockerfile`, stage 3 (the `FROM node:22-alpine` block, before `WORKDIR /app`'s `COPY` lines):

```dockerfile
# ── Stage 3: Production runtime ───────────────────────────
FROM node:22-alpine
WORKDIR /app

# Runs Claude Code sessions inside a named tmux session per lane so both the
# dashboard (via node-pty attach) and a real terminal can share one live pane.
RUN apk add --no-cache tmux

COPY --from=server-deps /app/node_modules ./node_modules/
```

- [ ] **Step 4: Add the `tmux_session` column and drop the now-unused `mode` NOT NULL requirement**

In `server/db.js`, find the `dashboard_runs` table definition (around line 248) and change:

```sql
  CREATE TABLE IF NOT EXISTS dashboard_runs (
    id TEXT PRIMARY KEY,
    session_id TEXT,
    mode TEXT NOT NULL,
    cwd TEXT NOT NULL,
```

to:

```sql
  CREATE TABLE IF NOT EXISTS dashboard_runs (
    id TEXT PRIMARY KEY,
    session_id TEXT,
    mode TEXT,
    cwd TEXT NOT NULL,
```

(`mode` stops being written by new code — see Task 4 — but stays nullable rather than dropped: SQLite's `ALTER TABLE ... DROP COLUMN` support varies across the `better-sqlite3`/`node:sqlite` fallback this repo supports, per `server/compat-sqlite.js`, and old rows already have real `headless`/`conversation` values worth keeping for history display. Making it nullable, not dropping it, is the lower-risk migration.)

Then add the new column via the same probe-then-`ALTER` pattern already used for `lane_id` right below it (find the `// Migrate: label a dashboard run with the lane it was started through` block around line 489-497 and add directly after it):

```javascript
// Migrate: tmux session name backing this run (tmux+PTY terminal design,
// 2026-08-11). Additive and nullable — historical rows spawned via the old
// stream-json mode have no tmux session and are display-only history now.
try {
  db.prepare("SELECT tmux_session FROM dashboard_runs LIMIT 1").get();
} catch {
  db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tmux_session TEXT").run();
}
```

- [ ] **Step 5: Verify the migration runs clean on a fresh DB and an existing one**

```bash
rm -f /tmp/ccam-migration-check.db
DASHBOARD_DB_PATH=/tmp/ccam-migration-check.db node -e "require('./server/db')" && echo OK
node --test server/__tests__/*.test.js 2>&1 | tail -20
```

Expected: `OK`, and the existing suite still passes (nothing else touches `dashboard_runs` shape yet).

- [ ] **Step 6: Commit**

```bash
git add package.json package-lock.json client/package.json client/package-lock.json Dockerfile server/db.js
git commit -m "chore: add node-pty/xterm deps, tmux in Docker, dashboard_runs.tmux_session column"
```

---

### Task 2: `server/lib/tmux.js` — low-level tmux command wrapper

**Files:**
- Create: `server/lib/tmux.js`
- Test: `server/__tests__/tmux.test.js`

**Interfaces:**
- Produces (consumed by Task 3):
  - `hasSession(name: string): boolean`
  - `newSession({name, cwd, argv}: {name: string, cwd: string, argv: string[]}): void` — `argv` is the command+args to run as the pane's process (e.g. `["claude", "--model", "opus"]`)
  - `killSession(name: string): void` — never throws if the session is already gone
  - `listSessions(prefix: string): string[]` — session names starting with `prefix`
  - `isTmuxAvailable(): boolean`
  - `__setExecImpl(fn)` / `__reset()` — test seam, same pattern as `run-spawner.js`'s `__injectChildForTest`/`__reset`

- [ ] **Step 1: Write the failing tests**

```javascript
// server/__tests__/tmux.test.js
/**
 * @file tmux.test.js
 * @description Unit tests for the tmux command wrapper. Injects a fake exec
 * implementation so the suite never shells out to a real `tmux` binary (CI
 * has none installed).
 * @author Nguyễn Ngọc Trí Vĩ <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**

```bash
node --test server/__tests__/tmux.test.js
```

Expected: FAIL — `Cannot find module '../lib/tmux'`.

- [ ] **Step 3: Implement `server/lib/tmux.js`**

```javascript
/**
 * @file tmux.js
 * @description Thin wrapper around the `tmux` CLI for the terminal-run
 * feature. Every dashboard-managed session is named `ccam-lane-<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**

```bash
node --test server/__tests__/tmux.test.js
```

Expected: PASS, all 7 tests.

- [ ] **Step 5: Commit**

```bash
git add server/lib/tmux.js server/__tests__/tmux.test.js
git commit -m "feat(run): add tmux command wrapper with an injectable exec seam"
```

---

### Task 3: `server/lib/pty-run.js` — run lifecycle (Start/Resume/Kill/List)

**Files:**
- Create: `server/lib/pty-run.js`
- Test: `server/__tests__/pty-run.test.js`

**Interfaces:**
- Consumes: `tmux.js`'s `hasSession`, `newSession`, `killSession`, `listSessions` (Task 2); `dashboard-runs.js`'s `recordRun`/`getRun`/`listRuns` (unchanged — Task 1's migration already made them tolerant of the new/nullable columns).
- Produces (consumed by Task 4's route rewrite and Task 5's WS attach):
  - `spawnRun({laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt}): {id: string, ...}` — `id` is the tmux session name.
  - `killRun(id: string): boolean`
  - `listRuns(): Array<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**

```javascript
// server/__tests__/pty-run.test.js
/**
 * @file pty-run.test.js
 * @description Unit tests for the tmux-backed run lifecycle. Injects a fake
 * tmux exec implementation (via tmux.js's test seam) so no real tmux binary
 * is invoked.
 * @author Nguyễn Ngọc Trí Vĩ <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**

```bash
node --test server/__tests__/pty-run.test.js
```

Expected: FAIL — `Cannot find module '../lib/pty-run'`.

- [ ] **Step 3: Implement `server/lib/pty-run.js`**

```javascript
/**
 * @file pty-run.js
 * @description Owns the tmux-backed run lifecycle for the dashboard's
 * terminal-run feature: Start (create-or-adopt), Resume (`--resume`), Kill,
 * and List. Unlike the old run-spawner.js, there is no in-memory handle Map —
 * `listRuns`/`getRun` are computed fresh from `tmux list-sessions` on every
 * call, the same "computed fact, never a stored one" principle this repo
 * already applies to lane runtime up/down (see CLAUDE.md). A session killed
 * out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects
 * on the next read instead of leaving a ghost "running" row.
 *
 * Every session is named `ccam-lane-<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**

```bash
node --test server/__tests__/pty-run.test.js
```

Expected: PASS, all 7 tests.

- [ ] **Step 5: Commit**

```bash
git add server/lib/pty-run.js server/__tests__/pty-run.test.js
git commit -m "feat(run): add tmux-backed run lifecycle (spawn/kill/list computed from tmux state)"
```

---

### Task 4: `server/routes/run.js` — rewrite for the tmux backend

**Files:**
- Modify: `server/routes/run.js` (rewrite `POST /`, `GET /`, `DELETE /:id`; remove `POST /:id/message`; keep `GET /cwds`, `GET /files`, `sameOriginGuard`, `sanitiseCwd` unchanged)
- Modify: `server/routes/run.js` — rename `GET /binary` to also report tmux, or add `GET /tmux` (see Step 3)
- Test: `server/__tests__/run.test.js` (rewrite the spawn/kill/list cases; delete the `/message` cases)

**Interfaces:**
- Consumes: `pty-run.js`'s `spawnRun`, `killRun`, `getRun`, `listRuns` (Task 3); `tmux.js`'s `isTmuxAvailable` (Task 2).
- Produces: `POST /api/run` now requires `laneId` (a terminal run only makes sense scoped to a lane's cwd — there is no more "spawn anywhere headless" use case now that every run is a live interactive pane a human is expected to look at).

- [ ] **Step 1: Update the route imports and remove the old spawner**

In `server/routes/run.js`, replace:

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

with:

```javascript
const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
```

- [ ] **Step 2: Replace `GET /`, `POST /`, and `DELETE /:id`**

Replace the block from `router.get("/", ...)` (line 98) through the end of `router.post("/", ...)` (line 310) with:

```javascript
router.get("/", (_req, res) => {
  res.json({ items: runs.listRuns() });
});

router.post("/", (req, res) => {
  const body = req.body || {};
  const laneId = Number.parseInt(String(body.laneId ?? ""), 10);
  if (!Number.isInteger(laneId)) {
    return res.status(400).json({ error: { code: "EBADLANE", message: "laneId is required" } });
  }
  let cwd;
  try {
    cwd = sanitiseCwd(body.cwd);
  } catch (err) {
    return res.status(400).json({ error: { code: err.code, message: err.message } });
  }
  try {
    const handle = runs.spawnRun({
      laneId,
      cwd,
      model: typeof body.model === "string" && body.model ? body.model : null,
      permissionMode:
        typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
          ? body.permissionMode
          : "acceptEdits",
      effort: typeof body.effort === "string" && body.effort ? body.effort : null,
      resumeSessionId:
        typeof body.resumeSessionId === "string" && body.resumeSessionId ? body.resumeSessionId : null,
      initialPrompt: typeof body.initialPrompt === "string" ? body.initialPrompt : "",
    });
    return res.status(201).json(handle);
  } catch (err) {
    if (err.code && err.code.startsWith("E")) {
      return res.status(400).json({ error: { code: err.code, message: err.message } });
    }
    return res.status(500).json({ error: { code: "EINTERNAL", message: err.message } });
  }
});
```

And replace `router.delete("/:id", ...)` (line 338) — unchanged in shape, but now calling the new module (already covered since `runs` was reassigned above; no further edit needed there). Delete the `router.post("/:id/message", ...)` block (lines 312-325) entirely — there is no more stdin-envelope input path; all input after Start goes through the WS binary channel (Task 5/8).

Also delete `router.get("/:id", ...)` (lines 327-336, the `?envelopes=1` handler) — replace with a plain single-run lookup:

```javascript
router.get("/:id", (req, res) => {
  const handle = runs.getRun(req.params.id);
  if (!handle || handle.status === "gone") {
    return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
  }
  return res.json(handle);
});
```

- [ ] **Step 3: Add a tmux-availability check next to the existing `claude`-on-PATH check**

Directly below the existing `router.get("/binary", ...)` handler (line 247-261, unchanged), add:

```javascript
router.get("/tmux", (_req, res) => {
  res.json({ available: tmux.isTmuxAvailable() });
});
```

- [ ] **Step 4: Update `GET /history` to stop cross-referencing the deleted in-memory Map**

In the `router.get("/history", ...)` handler (line 115), replace the "cross-reference with live handles" block:

```javascript
  // Cross-reference with live handles so the UI can mark which history
  // entries are still attached / running.
  const liveIds = new Set();
  for (const h of runs.listRuns()) {
    if (h.id && (h.status === "running" || h.status === "spawning")) liveIds.add(h.id);
  }
  res.json({
    items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })),
  });
```

with:

```javascript
  const liveIds = new Set(runs.listRuns().map((h) => h.id));
  res.json({
    items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })),
  });
```

(same intent, just reading the new module's already-computed live list — `runs.listRuns()` now only ever returns sessions tmux confirms exist, so every entry is inherently "live"; no more `status === "running"/"spawning"` filter needed.)

- [ ] **Step 5: Update the file header's mode description**

Replace the file's `@description` (lines 3-6) to no longer describe headless/conversation:

```javascript
 * @description HTTP routes for the dashboard's terminal-run feature. Starts,
 * resumes, kills, and lists tmux-backed `claude` sessions (one per lane),
 * streamed to the client over a dedicated WebSocket path (see
 * server/websocket.js `/ws-pty/:runId`) rather than this REST surface.
```

- [ ] **Step 6: Rewrite `server/__tests__/run.test.js`'s spawn/message/kill/list cases**

The file's `makeFakeChild()` helper and every test that depends on the old stream-json child process model (envelope injection, `/message` POST) no longer apply — those tests are for Task 8/9's deleted client concepts and this task's deleted route, not this module. Replace the whole file with:

```javascript
// server/__tests__/run.test.js
/**
 * @file run.test.js
 * @description Route tests for the terminal-run feature: same-origin guard,
 * laneId/cwd validation, spawn/kill/list against a mocked tmux backend.
 * @author Nguyễn Ngọc Trí Vĩ <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**

```bash
node --test server/__tests__/*.test.js 2>&1 | tail -40
```

Expected: PASS. If `GET /api/run/tmux` 404s, check Express route ordering — `router.get("/tmux", ...)` must be registered before any `router.get("/:id", ...)` for the same router, otherwise Express matches `:id = "tmux"` first (same class of bug this repo's own docs warn about for `/lanes/gc` vs `/lanes/:id`).

- [ ] **Step 8: Commit**

```bash
git add server/routes/run.js server/__tests__/run.test.js
git commit -m "feat(run): rewrite routes for the tmux backend, drop stdin-message endpoint"
```

---

### Task 5: `/ws-pty/:runId` — PTY WebSocket transport

**Files:**
- Create: `server/lib/pty-attach.js` (the `node-pty` attach logic, separated from `websocket.js` so it stays testable without a real socket)
- Modify: `server/websocket.js` (add the second `WebSocketServer`)
- Test: `server/__tests__/pty-attach.test.js`

**Interfaces:**
- Consumes: `pty-run.js`'s `laneIdFromRunId` (Task 3, for validating `runId` before touching tmux); `node-pty`'s `spawn`.
- Produces: `initPtyWebSocket(server)` (called from `server/index.js` next to the existing `initWebSocket(server)`), exported from `server/websocket.js`.

- [ ] **Step 1: Write the failing test for the attach helper**

```javascript
// server/__tests__/pty-attach.test.js
/**
 * @file pty-attach.test.js
 * @description Unit tests for the PTY-attach helper's runId validation and
 * data/control framing, using a fake node-pty implementation (no real tmux
 * or PTY spawned).
 * @author Nguyễn Ngọc Trí Vĩ <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**

```bash
node --test server/__tests__/pty-attach.test.js
```

Expected: FAIL — `Cannot find module '../lib/pty-attach'`.

- [ ] **Step 3: Implement `server/lib/pty-attach.js`**

```javascript
/**
 * @file pty-attach.js
 * @description Bridges one WebSocket connection to a `node-pty`-backed
 * `tmux attach-session` process. Binary WS frames carry raw PTY bytes in
 * both directions; text WS frames carry small JSON control messages
 * (`resize`, and an outbound `exit` sent once when the pane process/tmux
 * session ends). Multiple browser tabs each get their own PTY attach
 * process — tmux itself is what keeps them all in sync, this module does no
 * cross-connection coordination.
 * @author Nguyễn Ngọc Trí Vĩ <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**

```bash
node --test server/__tests__/pty-attach.test.js
```

Expected: PASS, all 6 tests.

- [ ] **Step 5: Wire the second WebSocket server in `server/websocket.js`**

Add below the existing `initWebSocket` function (after its closing brace, before `function broadcast`):

```javascript
let ptyWss = null;
const PTY_PATH_RE = /^\/ws-pty\/(ccam-lane-\d+)$/;

/**
 * Second WebSocket server, dedicated to the terminal-run PTY transport
 * (`/ws-pty/:runId`). Kept separate from the `/ws` JSON-broadcast path so
 * raw binary PTY frames never have to coexist with the typed
 * `{type, data, timestamp}` envelope the rest of the app relies on. Reuses
 * the exact same auth guard as `/ws`.
 *
 * `runId` is a path SEGMENT, not a fixed string, so this can't use the `ws`
 * library's `{server, path}` shorthand (that option only matches an exact
 * string). Instead this server is created with `noServer: true` and the
 * upgrade is handled manually — the same `server.on("upgrade", ...)` pattern
 * `ws` itself uses internally, just filtered to `/ws-pty/*` first so `/ws`'s
 * own upgrade handling (already registered by `initWebSocket`) is untouched.
 */
function initPtyWebSocket(server) {
  const { attach } = require("./lib/pty-attach");
  ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });

  ptyWss.on("connection", (ws, runId) => {
    try {
      attach(ws, runId, { cols: 80, rows: 24 });
    } catch (err) {
      try {
        ws.close(1008, err.message);
      } catch {
        /* ignore */
      }
    }
  });

  server.on("upgrade", (req, socket, head) => {
    const url = new URL(req.url, "http://localhost");
    const match = url.pathname.match(PTY_PATH_RE);
    if (!match) return; // not ours — the `/ws` WebSocketServer (registered
    // by initWebSocket, also attached to this same http.Server) handles its
    // own path independently and ignores upgrades it doesn't match too.
    if (!isHostAllowed(req.headers.host)) {
      socket.destroy();
      return;
    }
    if (!isWebSocketAuthorized(req)) {
      socket.destroy();
      return;
    }
    ptyWss.handleUpgrade(req, socket, head, (ws) => {
      ptyWss.emit("connection", ws, match[1]);
    });
  });

  return ptyWss;
}
```

Update the file's `module.exports` (last line) to include the new function, and update `closeWebSocket` to also terminate `ptyWss` clients:

```javascript
function closeWebSocket() {
  if (wss) {
    wss.clients.forEach((client) => {
      try {
        client.terminate();
      } catch {
        /* already gone */
      }
    });
    try {
      wss.close();
    } catch {
      /* ignore */
    }
    wss = null;
  }
  if (ptyWss) {
    ptyWss.clients.forEach((client) => {
      try {
        client.terminate();
      } catch {
        /* already gone */
      }
    });
    try {
      ptyWss.close();
    } catch {
      /* ignore */
    }
    ptyWss = null;
  }
}

module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket };
```

(Delete the old standalone `closeWebSocket` definition — this replaces it, don't leave two.)

- [ ] **Step 6: Wire `initPtyWebSocket` in `server/index.js`**

Find `initWebSocket(server);` (line 152) and add directly below it:

```javascript
  initWebSocket(server);
  require("./websocket").initPtyWebSocket(server);
```

- [ ] **Step 7: Manual smoke check (real tmux, real node-pty — not part of the automated suite)**

```bash
npm start &
sleep 1
tmux new-session -d -s ccam-lane-999 -c /tmp -- sleep 60
node -e "
const WebSocket = require('ws');
const ws = new WebSocket('ws://127.0.0.1:4820/ws-pty/ccam-lane-999');
ws.on('open', () => console.log('connected'));
ws.on('message', (d) => console.log('got', d.length, 'bytes'));
ws.on('close', (code) => console.log('closed', code));
setTimeout(() => process.exit(0), 3000);
"
tmux kill-session -t ccam-lane-999
kill %1
```

Expected: `connected` printed, no error, process exits cleanly at the 3s timeout.

- [ ] **Step 8: Commit**

```bash
git add server/lib/pty-attach.js server/__tests__/pty-attach.test.js server/websocket.js server/index.js
git commit -m "feat(run): add /ws-pty/:runId PTY transport bridging WS to tmux attach"
```

---

### Task 6: `TerminalView.tsx` — the xterm.js client component

**Files:**
- Create: `client/src/components/run/TerminalView.tsx`
- Test: `client/src/components/run/__tests__/TerminalView.test.tsx`

**Interfaces:**
- Consumes: `@xterm/xterm`'s `Terminal`, `@xterm/addon-fit`'s `FitAddon`.
- Produces: `<TerminalView runId={string} wsBaseUrl={string} />` — a self-contained component; Task 9's Workspace wiring is the only caller.

- [ ] **Step 1: Write the failing test**

```typescript
// client/src/components/run/__tests__/TerminalView.test.tsx
/**
 * @file TerminalView.test.tsx
 * @description Tests for the xterm.js-backed terminal view: verifies it opens
 * a WS connection to the right URL, writes incoming binary frames to the
 * mocked terminal, and forwards typed input as outgoing binary frames.
 * @author Nguyễn Ngọc Trí Vĩ <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**

```bash
cd client && npx vitest run src/components/run/__tests__/TerminalView.test.tsx
```

Expected: FAIL — `Failed to resolve import "../TerminalView"`.

- [ ] **Step 3: Implement `TerminalView.tsx`**

```typescript
/**
 * @file TerminalView.tsx
 * @description Renders one lane's live terminal — a real `xterm.js` instance
 * attached via WebSocket to the server's `/ws-pty/:runId` path (see
 * server/lib/pty-attach.js), which is itself a `node-pty`-backed
 * `tmux attach-session`. Binary WS frames are raw PTY bytes in both
 * directions; a JSON text frame carries the initial `resize` on mount and
 * the server's one-shot `exit` notice when the pane process ends.
 * @author Nguyễn Ngọc Trí Vĩ <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**

```bash
cd client && npx vitest run src/components/run/__tests__/TerminalView.test.tsx
```

Expected: PASS, all 3 tests. If `ResizeObserver is not defined` appears, add to `client/vitest.config.ts`'s existing test setup file a `global.ResizeObserver = class { observe() {} disconnect() {} };` stub (check `client/src/test/setup.ts` or equivalent referenced by `vitest.config.ts`'s `setupFiles` first — this repo already has one for other DOM APIs jsdom lacks).

- [ ] **Step 5: Commit**

```bash
git add client/src/components/run/TerminalView.tsx client/src/components/run/__tests__/TerminalView.test.tsx
git commit -m "feat(run): add TerminalView xterm.js component for the PTY transport"
```

---

### Task 7: `client/src/lib/api.ts` and `types.ts` — new run types and endpoints

**Files:**
- Modify: `client/src/lib/api.ts:2510-2621` (types), `:1476-1503` (the `run` object)
- Modify: `client/src/lib/types.ts:1279-1327` (delete `RunStreamPayload`, `RunInputAckPayload`; keep/trim `RunStatusPayload` — still useful for the run-history list's live/dead badge, no envelope-specific fields needed)

**Interfaces:**
- Produces: `api.run.list()`, `api.run.start(args)`, `api.run.kill(id)`, `api.run.tmuxAvailable()`, `RunHandle`, `RunStartArgs` — the shapes Task 8's `RunSetup`/`RunHistory` edits and Task 9's `Workspace` wiring consume.

- [ ] **Step 1: Replace the type block in `api.ts`**

Replace the entire block from `export type RunMode = ...` (line 2510) through the end of `RunListResponse` (line 2581) with:

```typescript
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions";
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
 *  crashed, claude exited and closed the pane). Computed fresh from tmux
 *  state on every read, never cached. */
export type RunStatus = "running" | "gone";

/** Body for POST /api/run - parameters for starting a lane's terminal run. */
export interface RunStartArgs {
  laneId: number;
  cwd?: string;
  model?: string;
  permissionMode?: PermissionMode;
  /** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
  resumeSessionId?: string;
  effort?: EffortLevel;
  /** Sent as `claude`'s first positional message once the pane boots; omit
   *  to just open the pane and let the user type. */
  initialPrompt?: string;
}

/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
 *  name (`ccam-lane-<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`:

```typescript
export interface DashboardRunHistoryItem {
  id: string;
  session_id: string | null;
  cwd: string;
  model: string | null;
  permission_mode: PermissionMode | null;
  effort: EffortLevel | null;
  resume_session_id: string | null;
  prompt_preview: string | null;
  status: "running" | "killed" | "abandoned";
  exit_code: number | null;
  started_at: string;
  ended_at: string | null;
  isLive: boolean;
}
```

- [ ] **Step 2: Replace the `api.run` object**

Replace the whole block from `run: {` (line 1476) through its closing `},` (before the next top-level key) with:

```typescript
  run: {
    /** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
    list: () => request<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:

```typescript
// ───── Terminal run status ─────
// A lane's terminal run is a tmux session; its own live/dead state is polled
// via GET /api/run, not pushed over the WS envelope path. This payload only
// covers the one thing worth pushing live: a run ending (killed or the pane
// process exiting) so an open Workspace tab can update its badge/switcher
// without polling.

/** Payload for the `run_status` WebSocket message. */
export interface RunStatusPayload {
  /** The run id (tmux session name, `ccam-lane-<laneId>`). */
  id: string;
  status: "running" | "gone";
  /** Epoch-ms timestamp of this transition. */
  at: number;
}
```

- [ ] **Step 4: Type-check the client**

```bash
cd client && npx tsc --noEmit 2>&1 | head -60
```

Expected: errors ONLY in files this plan hasn't touched yet (`RunConsole.tsx`, `RunSetup.tsx`, `RunHistory.tsx`, `Workspace.tsx`, `useRunStream.ts`) — those are fixed in Tasks 8-10. If you see errors in `api.ts`/`types.ts` themselves, fix them before proceeding.

- [ ] **Step 5: Commit**

```bash
git add client/src/lib/api.ts client/src/lib/types.ts
git commit -m "feat(run): replace RunHandle/RunStartArgs types and api.run for the tmux backend"
```

---

### Task 8: Delete the stream-json client code and server pieces it made obsolete

**Files:**
- Delete: `client/src/components/run/RunConsole.tsx`
- Delete: `client/src/hooks/useRunStream.ts`, `client/src/hooks/__tests__/useRunStream.test.tsx`
- Delete: `server/lib/run-spawner.js`, `server/__tests__/run.test.js`'s old fake-child helper (already replaced in Task 4 Step 6 — this step is the actual `rm`, confirming Task 4 already left nothing pointing at the old file)
- Modify: `server/lib/stream-json-parser.js` — delete ONLY if Step 1's grep confirms no other caller

**Interfaces:**
- Consumes: nothing new.
- Produces: a clean tree with no dangling imports of the deleted files (verified in Step 3).

- [ ] **Step 1: Confirm `stream-json-parser.js` has no caller outside the deleted spawner**

```bash
grep -rln "stream-json-parser" --include=*.js --include=*.ts server/ | grep -v node_modules
```

Expected: only `server/lib/run-spawner.js` (about to be deleted). If anything else shows up, stop and leave `stream-json-parser.js` in place — do not delete it.

- [ ] **Step 2: Delete the files**

```bash
git rm client/src/components/run/RunConsole.tsx
git rm client/src/hooks/useRunStream.ts client/src/hooks/__tests__/useRunStream.test.tsx
git rm server/lib/run-spawner.js
git rm server/lib/stream-json-parser.js   # only if Step 1's grep was clean
find server/__tests__ -iname "*stream-json-parser*" -exec git rm {} \;   # if such a test file exists
```

- [ ] **Step 3: Grep for dangling references**

```bash
grep -rln "run-spawner\|useRunStream\|RunConsole\|stream-json-parser" --include=*.js --include=*.ts --include=*.tsx client/src server | grep -v node_modules
```

Expected: empty, OR only `client/src/components/run/RunSetup.tsx` and `client/src/components/run/RunHistory.tsx` and `client/src/pages/Workspace.tsx` — those three are fixed in Tasks 9-10, not this task.

- [ ] **Step 4: Run both suites to see the expected remaining breakage**

```bash
node --test server/__tests__/*.test.js 2>&1 | tail -20
cd client && npx vitest run 2>&1 | tail -40
```

Expected: server suite PASSES (nothing else referenced `run-spawner.js`/`stream-json-parser.js`). Client suite FAILS only on files Tasks 9-10 haven't touched yet (`RunSetup.test.tsx`, `RunHistory`-related tests, `Workspace.test.tsx`, `screens.snapshot.test.tsx`) — confirm the failures are import errors pointing at `RunConsole`/`useRunStream`, not something unrelated.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "chore(run): delete stream-json spawner, parser, and chat-bubble console"
```

---

### Task 9: `RunSetup.tsx` and `RunHistory.tsx` — adjust to the new data shape

**Files:**
- Modify: `client/src/components/run/RunSetup.tsx`
- Modify: `client/src/components/run/RunHistory.tsx`
- Test: existing test files for both (adjust, don't rewrite from scratch — the picker/switcher UI itself is unchanged, only the data it submits/reads)

**Interfaces:**
- Consumes: `RunStartArgs`, `RunHandle`, `DashboardRunHistoryItem` (Task 7).
- Produces: `RunSetup`'s `onSubmit` now calls the caller with a `RunStartArgs`-shaped object (no more `mode`); `RunHistory`'s `ActiveRunsSwitcher`/`RunsModal` render `RunHandle`/`DashboardRunHistoryItem` without a mode badge.

- [ ] **Step 1: Read `RunSetup.tsx`'s current mode-toggle UI and submit handler**

```bash
grep -n "mode\b" client/src/components/run/RunSetup.tsx | head -40
```

This surfaces every line touching the headless/conversation toggle — the exact lines to remove depend on what that grep returns (this file wasn't fully quoted in the spec's research pass). Locate:
1. The `mode` field in `RunSetupProps`/local state (`useState<"headless" | "conversation">`).
2. The toggle UI (likely a `<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:

```typescript
onSubmit({
  laneId,
  cwd,
  model: model || undefined,
  permissionMode,
  effort: effort || undefined,
  resumeSessionId: resumeSessionId || undefined,
  initialPrompt: prompt || undefined,
});
```

(`laneId` must already be available to this component from its props — check `RunSetupProps`; if it currently only receives `cwd`, add `laneId: number` to `RunSetupProps` and thread it from `Workspace.tsx` in Task 10, since every run is now lane-scoped by construction.)

The prompt textarea's label/placeholder text should change from something like "First message" (framed as the one-shot headless prompt) to "Send once the terminal opens (optional)" — grep the file for its current placeholder string and update the copy to match the new semantics; keep the JSX structure otherwise unchanged.

- [ ] **Step 3: Remove `ModeBadge` and `mode`-based rendering in `RunHistory.tsx`**

```bash
grep -n "\bmode\b\|ModeBadge\|RunMode" client/src/components/run/RunHistory.tsx
```

`ModeBadge` itself lives in the just-deleted `RunConsole.tsx` (Task 8) — every import/usage of it in `RunHistory.tsx` must be removed. Each history row currently showing a mode badge should instead show nothing in that slot (the row's other badges — status, resume-vs-fresh — are unaffected).

- [ ] **Step 4: Update `UnifiedRunRow` / kill / resume actions to call the new API shape**

`RunHistory.tsx`'s kill button already calls `api.run.kill(id)` (Task 7 kept that signature identical — no change needed there). Its resume action (if it directly calls `api.run.start`) must drop `mode`/`prompt` in favor of `resumeSessionId`/`initialPrompt` per the new `RunStartArgs`.

- [ ] **Step 5: Fix the adjusted tests**

```bash
find client/src/components/run/__tests__ -iname "*RunSetup*" -o -iname "*RunHistory*"
```

For each matching test file: remove any test asserting the mode toggle's presence/behavior; remove any `mode:` field from mocked `RunStartArgs`/`RunHandle`/`DashboardRunHistoryItem` fixtures (TypeScript will already flag these via Step 6's type-check — fix compile errors first, then re-run).

- [ ] **Step 6: Type-check and run the client suite**

```bash
cd client && npx tsc --noEmit 2>&1 | head -60
npx vitest run src/components/run 2>&1 | tail -60
```

Expected: no errors in `RunSetup.tsx`/`RunHistory.tsx` or their tests. Remaining errors, if any, are confined to `Workspace.tsx` (Task 10).

- [ ] **Step 7: Commit**

```bash
git add client/src/components/run/RunSetup.tsx client/src/components/run/RunHistory.tsx client/src/components/run/__tests__
git commit -m "feat(run): adjust RunSetup/RunHistory to the tmux RunStartArgs/RunHandle shape"
```

---

### Task 10: `Workspace.tsx` — swap `RunConsole`/`useRunStream` for `TerminalView`

**Files:**
- Modify: `client/src/pages/Workspace.tsx`
- Modify: `client/src/pages/__tests__/Workspace.test.tsx`

**Interfaces:**
- Consumes: `TerminalView` (Task 6), the new `api.run`/`RunHandle`/`RunStartArgs` (Task 7), `RunSetup`/`ActiveRunsSwitcher` (Task 9).

- [ ] **Step 1: Locate every reference to the deleted pieces**

```bash
grep -n "RunConsole\|useRunStream\|envelopes\b" client/src/pages/Workspace.tsx
```

- [ ] **Step 2: Replace the import**

```typescript
// before
import { RunConsole } from "../components/run/RunConsole";
import { useRunStream } from "../hooks/useRunStream";
// after
import { TerminalView } from "../components/run/TerminalView";
```

- [ ] **Step 3: Remove the `useRunStream` call and its derived state**

Delete the `useRunStream(...)` call (around line 65 per the earlier research) and its `envelopes`/`displayEnvelopes`/`onStatus`/`onInputAck`/`onAnyStatus` plumbing. The `selectedRunId`/`runHandle` state that already exists stays — it's still how the page tracks which lane's run is open; only the envelope-streaming half goes away.

- [ ] **Step 4: Replace the `<RunConsole ... />` render with `<TerminalView />`**

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

```typescript
const handle = await api.run.start({
  laneId: currentLane.id,
  cwd: currentLane.cwd,
  model: selectedModel || undefined,
  permissionMode: selectedPermissionMode,
  effort: selectedEffort || undefined,
  resumeSessionId: resumeTarget?.session_id,
  initialPrompt: promptText || undefined,
});
setRunHandle(handle);
```

Remove any `mode: "headless" | "conversation"` selection state this page owned for the run-setup flow if `RunSetup.tsx` no longer emits it (Task 9 already removed it from `RunSetup`'s output — this page must stop reading a `mode` field from that callback's argument object).

- [ ] **Step 6: Update `Workspace.test.tsx`'s run-related mocks**

```bash
grep -n "api.run\.\|RunConsole\|useRunStream\|mode:" client/src/pages/__tests__/Workspace.test.tsx
```

Update the mocked `api.run.start`/`api.run.list`/`api.run.kill` implementations to return the new `RunHandle` shape (no `mode`, `argv`, `envelopeCount`, etc. — see Task 7's type). Remove any assertion that inspects `RunConsole`-rendered chat bubbles; replace with an assertion that `TerminalView`'s `data-testid="terminal-view"` container renders once a run is active (mock `TerminalView` itself with `vi.mock("../../components/run/TerminalView", ...)` returning a stub `<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**

```bash
cd client && npx tsc --noEmit 2>&1 | head -60
npx vitest run 2>&1 | tail -80
```

Once everything but the snapshot passes:

```bash
cd client && npx vitest run -u
git diff client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
```

Review the diff — expect changes ONLY in the Workspace screen's run panel (mode badge gone, terminal container instead of chat bubbles). Anything else changing is a regression — investigate before accepting.

- [ ] **Step 8: Commit**

```bash
git add client/src/pages/Workspace.tsx client/src/pages/__tests__/Workspace.test.tsx client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap
git commit -m "feat(run): wire Workspace to TerminalView, drop stream-json envelope plumbing"
```

---

### Task 11: `ccam lanes shell` CLI command

**Files:**
- Modify: `bin/ccam.js` (dispatch table + new `cmdLanesShell` function)

**Interfaces:**
- Consumes: `resolveLaneArg` (existing helper, same one `cmdLanesPipeline`/`cmdStage` use), `get`/`patch` HTTP helpers (existing), `node:child_process`'s `spawn` (already imported at the top of this file per the earlier research).

- [ ] **Step 1: Add the dispatch entry**

Find the `case "lanes":` block's if-chain (around line 3333-3368 per the earlier research) and add, alongside the other `rest[0]` checks (order doesn't matter among siblings, but keep it near `pipeline`/`gc` for readability):

```javascript
if (rest[0] === "shell") return cmdLanesShell(rest.slice(1));
```

- [ ] **Step 2: Implement `cmdLanesShell`**

Add this function near `cmdLanesPipeline` (same file, same section):

```javascript
/**
 * Attach a real terminal to the exact tmux session the dashboard uses for
 * this lane's run. Creates it if it doesn't exist yet (`tmux new-session -A`
 * is create-or-attach, the same idempotent semantics as clicking Start on
 * the dashboard). The user types `claude` themselves inside — this command's
 * only job is landing them in the right named session.
 */
async function cmdLanesShell(args) {
  const resolved = await resolveLaneArg(args);
  if (!resolved) return;
  const { lane } = await get(`/api/lanes/${resolved.laneId}`);
  const sessionName = `ccam-lane-${lane.id}`;
  const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
    stdio: "inherit",
  });
  await new Promise((resolve) => {
    child.on("close", resolve);
    child.on("error", (err) => {
      console.error(c.red(`✖ ${err?.message || err}`));
      process.exitCode = 1;
      resolve();
    });
  });
}
```

- [ ] **Step 3: Manual verification (interactive — not part of the automated suite)**

```bash
cd /path/to/some/adopted/lane
node /path/to/ccam-lanes/bin/ccam.js lanes shell
```

Expected: drops into a tmux session named `ccam-lane-<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**

```bash
grep -n '"help"' bin/ccam.js | head -5
```

Add a one-line entry for `ccam lanes shell` next to the existing `ccam lanes pipeline` entry in whatever help-text block that grep surfaces (match the existing entries' format exactly).

- [ ] **Step 5: Commit**

```bash
git add bin/ccam.js
git commit -m "feat(cli): add 'ccam lanes shell' to attach a real terminal to a lane's tmux session"
```

---

### Task 12: Docs — README, ARCHITECTURE, docs/API.md, docs/LANES.md

**Files:**
- Modify: `README.md` (the "Run Claude from the browser" bullet under "What it does")
- Modify: `README.vi.md` (mirror the same edit — this repo keeps a Vietnamese translation in sync)
- Modify: `ARCHITECTURE.md` (find and update whatever section documents the old stream-json Run feature — search first)
- Modify: `docs/API.md` (the `/api/run/*` endpoint reference)
- Modify: `docs/LANES.md` (mention `ccam lanes shell` next to the existing `ccam lanes pipeline` documentation added in the prior session's work)

**Interfaces:** none — documentation only.

- [ ] **Step 1: Find every doc section describing the old Run feature**

```bash
grep -rln "stream-json\|RunConsole\|headless\|conversation mode\|POST /api/run" README.md README.vi.md ARCHITECTURE.md docs/*.md
```

- [ ] **Step 2: Update `README.md`'s bullet**

Replace:

```markdown
- **Run Claude from the browser.** Spawn a session in a lane's directory, stream
  its output, send follow-ups, resume any past session.
```

with:

```markdown
- **Run Claude from the browser.** A real terminal (tmux + a real PTY,
  rendered with xterm.js) attached to a lane's directory — the exact TUI you'd
  see locally, fully interactive, resumable, and attachable from a real
  terminal too via `ccam lanes shell`.
```

- [ ] **Step 3: Mirror the same edit in `README.vi.md`**

Find the corresponding Vietnamese bullet (search for "Chạy Claude từ trình duyệt") and translate the same change, keeping this repo's existing convention of preserving English technical terms (tmux, PTY, xterm.js, TUI) verbatim.

- [ ] **Step 4: Update `docs/API.md`'s `/api/run` section**

Rewrite the endpoint table/prose to match Task 4's actual routes: `GET /api/run`, `GET /api/run/history`, `GET /api/run/binary`, `GET /api/run/tmux`, `GET /api/run/cwds`, `GET /api/run/files`, `POST /api/run` (now requires `laneId`, body shape per `RunStartArgs`), `GET /api/run/:id`, `DELETE /api/run/:id`. Remove the `POST /api/run/:id/message` entry entirely (deleted in Task 4) and any `run_stream`/`run_input_ack` WebSocket message documentation (deleted in Task 7) — add a `/ws-pty/:runId` entry describing the binary-frame PTY transport instead, matching this doc's existing WebSocket-message table format.

- [ ] **Step 5: Add `ccam lanes shell` to `docs/LANES.md`**

Find the existing `ccam lanes pipeline` documentation (added in the prior session, per this repo's `docs/LANES.md` "Reporting a stage"/pipeline sections) and add a short paragraph for `ccam lanes shell` directly after it, matching that section's tone:

```markdown
`ccam lanes shell` attaches a real terminal to the exact tmux session the
dashboard's Start/Resume buttons use for this lane (`ccam-lane-<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**

```bash
grep -rln "stream-json\|RunConsole\|useRunStream\|run_stream\b\|run_input_ack" README.md README.vi.md ARCHITECTURE.md docs/*.md
```

Expected: empty.

- [ ] **Step 7: Commit**

```bash
git add README.md README.vi.md ARCHITECTURE.md docs/API.md docs/LANES.md
git commit -m "docs: update Run feature docs for the tmux+PTY terminal (was stream-json)"
```

---

### Task 13: Full-suite verification

**Files:** none — verification only.

- [ ] **Step 1: Full server suite**

```bash
npm run test:server 2>&1 | tail -30
```

Expected: all green, including Tasks 2/3/4/5's new test files.

- [ ] **Step 2: Full client suite**

```bash
npm run test:client 2>&1 | tail -60
```

Expected: all green, including the regenerated snapshot from Task 10.

- [ ] **Step 3: Client build and typecheck**

```bash
cd client && npx tsc --noEmit && npm run build
```

Expected: no type errors, build succeeds.

- [ ] **Step 4: File-header audit (this repo requires it on every touched file)**

```bash
bash .claude/skills/file-headers/scripts/check-headers.sh
```

Expected: exit 0 (aside from the pre-existing, unrelated `.ccam/profile/hooks/*.sh` gaps noted in this repo's own history — do not fix those as part of this plan).

- [ ] **Step 5: Manual end-to-end click-through (per the spec's "Verify" section)**

Install `tmux` locally if not already present, then:
1. `npm run dev`, open Workspace for an adopted lane.
2. Click Start — confirm the real Claude Code TUI renders in the browser via `TerminalView`.
3. Type in the browser terminal — confirm input reaches the pane.
4. In a real terminal, run `ccam lanes shell` for the same lane — confirm it drops into the exact same live session (typed output appears in both places).
5. Click Kill on the dashboard — confirm the tmux session ends and the real terminal's attach also exits.
6. Confirm `GET /api/run` no longer lists that run once killed.

- [ ] **Step 6: Final commit (only if Steps 1-5 turned up fixes)**

```bash
git add -A
git commit -m "fix: address issues found in full-suite verification"
```
