diff --git a/CLAUDE.md b/CLAUDE.md
index 9b0d35b7..7f1688e4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,5 +1,7 @@
# Claude Code Working Guide
+_Last synced: d884d19 (2026-08-26)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
+
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
@@ -33,6 +35,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
- **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep.
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
+- **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`.
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
diff --git a/README.md b/README.md
index 51edc81c..55610d78 100644
--- a/README.md
+++ b/README.md
@@ -17,10 +17,11 @@ Internal build — all rights reserved.
- **Stage detection.** The stage is inferred from the tool stream, so a session
that never calls `ccam stage` still shows progress — rendered dashed amber and
never as done, because an inference is not evidence.
-- **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`.
+- **Run Claude Code or Codex 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`. Which CLI it starts is the lane's own
+ setting.
- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus an MCP server and a CLI.
## Requirements
@@ -101,6 +102,13 @@ ccam lanes add --cwd /path/to/repo --title "My feature" # adopt
ccam lanes add --repo /path/to/repo --slug my-feature # managed worktree
```
+Every lane also carries the CLI that drives it — `claude` (the default) or
+`codex` — picked in the Add-lane modal and fixed for the life of the lane. A
+Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
+choices, and resumes with `codex resume --last`. Codex emits none of this
+dashboard's hooks, so a Codex lane records its runs but stays absent from
+Sessions, Analytics and stage detection.
+
The declared stage comes from `ccam stage`. The inferred stage comes from tool
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
move backwards between work sessions. Detection never writes the declared stage,
diff --git a/README.vi.md b/README.vi.md
index 032aef73..73414011 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -18,9 +18,12 @@ Internal build — all rights reserved.
session không bao giờ gọi `ccam stage` vẫn hiển thị progress — render dạng
viền chấm màu hổ phách (amber) và không bao giờ hiện `done`, vì suy luận
không phải bằng chứng.
-- **Chạy Claude từ trình duyệt.** Spawn session trong thư mục của lane, stream
- output, gửi follow-up, resume session cũ bất kỳ.
-- **Analytics, alert, Kanban và workflow view**, cộng thêm MCP server và CLI.
+- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
+ thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
+ máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
+ `ccam lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane.
+- **Analytics (tab trong dashboard), alert và workflow view**, cộng thêm MCP
+ server và CLI.
## Yêu cầu
@@ -99,6 +102,13 @@ ccam lanes add --cwd /path/to/repo --title "My feature" # adopt
ccam lanes add --repo /path/to/repo --slug my-feature # managed worktree
```
+Mỗi lane còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
+chọn trong modal Add lane và cố định suốt vòng đời của lane. Lane Codex có panel
+chạy với các lựa chọn riêng của Codex (sandbox, approval, reasoning effort) và
+resume bằng `codex resume --last`. Codex không bắn hook của dashboard này, nên
+lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions, Analytics và
+stage detection.
+
Stage khai báo (declared) đến từ `ccam stage`. Stage suy luận (inferred) đến
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có
thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai
diff --git a/client/README.md b/client/README.md
index c25d1a04..7fa2bc7a 100644
--- a/client/README.md
+++ b/client/README.md
@@ -449,11 +449,13 @@ graph TB
Feed["/activity (redirect)"]
Workflows["/workflows"]
CcConfig["/cc-config (redirect)"]
- Run["/run"]
+ Lanes["/lanes (redirect)"]
+ Run["/run (redirect)"]
+ Workspace["/workspace"]
Settings["/settings (tabs: dashboard | claude-code | ...)"]
NF["/* (NotFound)"]
- Root --> Dashboard & Sessions & Detail & Feed & Workflows & CcConfig & Run & Settings & NF
+ Root --> Dashboard & Sessions & Detail & Feed & Workflows & CcConfig & Lanes & Run & Workspace & Settings & NF
style Dashboard fill:#3B82F6
style Detail fill:#3B82F6
@@ -479,7 +481,10 @@ function App() {
} />
{/* legacy path - Claude config now lives in a Settings tab */}
} />
- } />
+ {/* legacy paths - lanes and runs are one page now */}
+ } />
+ } />
+ } />
} />
} />
diff --git a/server/README.md b/server/README.md
index 7d0c86c0..506773ea 100644
--- a/server/README.md
+++ b/server/README.md
@@ -651,22 +651,25 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
| `PUT` | `/api/cc-config/file` | Create or overwrite a text-file artifact (skills/agents/commands/output-styles/memory). Body: `{ scope, type, name?, content }`. Auto-backs-up if file exists. Atomic temp + rename. 256 KB cap. Per-project file-based memory is also editable via `{ scope: "auto-memory", type: "auto-memory", project, name }` — backups land under `/.cc-config-backups/auto-memory/`, and an invalid project slug returns `EBADPROJECT` |
| `DELETE` | `/api/cc-config/file` | Backup-then-delete a text-file artifact. Skill dirs are backed up whole before recursive removal |
-### Run Claude (`/api/run`)
+### Run a lane's CLI (`/api/run`)
-HTTP surface for spawning and supervising `claude` subprocesses from the dashboard. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
+HTTP surface for the tmux-backed terminal run: one session per lane, named `ccam-lane-`, running the CLI that lane declares (`claude` or `codex`). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
| Method | Path | Description |
| -------- | ----------------------------- | ----------- |
-| `GET` | `/api/run` | List handles + `maxConcurrent` + `activeCount` |
-| `GET` | `/api/run/binary` | Probe whether `claude` is on `PATH` |
+| `GET` | `/api/run` | `{ items }` — every live run handle, recomputed from tmux |
+| `GET` | `/api/run/history?limit=&laneId=` | Persisted run history from `dashboard_runs` (default 50, max 500, newest first); each row carries `isLive` and the `tool` it was spawned with. `laneId` narrows to one lane |
+| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
+| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
-| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the Run page's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
-| `POST` | `/api/run` | Spawn. Body: `{ prompt, mode, cwd?, model?, permissionMode?, resumeSessionId?, effort? }`. `effort` (`low`/`medium`/`high`) maps to `--effort`. When `resumeSessionId` is set in conversation mode, `prompt` may be empty — the spawner skips the initial stdin write and `claude --resume` idles until the client POSTs a follow-up to `/api/run/:id/message`. Spawner always passes `--output-format stream-json --verbose --include-partial-messages` for character-by-character streaming. Concurrency is effectively uncapped by default (ceiling 10000, override with `RUN_MAX_CONCURRENT`) — the terminal TUI has no cap and neither does the dashboard; the ceiling is sanity-only to prevent fork-bomb footguns |
-| `POST` | `/api/run/:id/message` | Send follow-up turn (conversation mode only). Body: `{ text }` |
-| `GET` | `/api/run/:id` | Handle state. `?envelopes=1` includes the in-memory envelope log for re-attach |
-| `DELETE` | `/api/run/:id` | Stop (SIGTERM → SIGKILL after 5 s) |
+| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
+| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
+| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
+| `DELETE` | `/api/run/:id` | Kill the tmux session |
-WebSocket message types added: `run_stream` (parsed stream-json envelope, including `stream_event` deltas from `--include-partial-messages`), `run_status` (status transitions), `run_input_ack` (stdin write confirmed), and `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`).
+Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the lane action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
+
+The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`).
### Import History