diff --git a/docs/superpowers/plans/2026-08-26-codex-lanes.md b/docs/superpowers/plans/2026-08-26-codex-lanes.md new file mode 100644 index 00000000..dbcdf0ee --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-codex-lanes.md @@ -0,0 +1,883 @@ +# Codex Lanes 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:** A lane can be driven by the `codex` CLI instead of `claude`, started and watched from the Workspace page with the same lane strip, pipeline map, terminal console and run history. + +**Architecture:** The tool is a lane-level, immutable column (`lanes.tool`), chosen in the Add-lane modal. `pty-run.js` branches its argv builder on that tool; everything else (tmux session naming `ccam-lane-`, create-or-adopt, console attach) is untouched. Runs record their tool in `dashboard_runs.tool` so history and the active-runs list can badge them. + +**Tech Stack:** Node 20 + Express + better-sqlite3 (server, `node:test`), React 18 + Vite + TypeScript + Vitest/Testing Library (client), tmux for panes. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-08-26-workspace-codex-lane-design.md`. +- Every source file created or edited must carry the project header (`@file`, `@description`, and the exact line `@author Nguyễn Ngọc Trí Vĩ `). Verify with `bash skills/file-headers/scripts/check-headers.sh`. +- Tool values: exactly `"claude"` and `"codex"`. Default everywhere: `"claude"`. +- Codex allowlists — sandbox: `read-only | workspace-write | danger-full-access`; approval: `on-request | never`; reasoning effort: `low | medium | high | xhigh`. +- Never emit `--dangerously-bypass-approvals-and-sandbox` or `--dangerously-bypass-hook-trust`. +- A lane's `tool` is immutable after creation: never added to `PATCHABLE`. +- Response shapes are additive only — no existing field renamed or removed. +- User-visible strings go through i18n, never literals in components. +- Server tests: `npm run test:server`. Client tests: `npm run test:client`. + +--- + +### Task 1: `tool` column on lanes and runs + +**Files:** +- Modify: `server/db.js:486` (after the `slug` probe, before the `lane_id` probe block at 491) +- Modify: `server/lib/lanes.js:64-76` (PATCHABLE — leave unchanged, assert in test), `server/lib/lanes.js:92-96` (validators), `server/lib/lanes.js:133-163` (createLane) +- Modify: `server/lib/dashboard-runs.js:25-46` (insert statement + `RUN_COLUMNS`), `server/lib/dashboard-runs.js:72-96` (recordRun) +- Test: `server/__tests__/lanes-lib.test.js`, `server/__tests__/lane-data.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `lanesLib.createLane({..., tool})` → lane row with `tool: "claude" | "codex"`; throws `Error` with `code === "EBADTOOL"` on anything else. + - `lanesLib.VALID_TOOLS` — `Set` exported for route validation. + - `dashboardRuns.recordRun({..., tool})` persists `dashboard_runs.tool`; `listRuns`/`getRun` rows carry `tool`. + +- [ ] **Step 1: Write the failing tests** + +In `server/__tests__/lanes-lib.test.js`, inside the existing top-level `describe`, add: + +```js +it("createLane defaults tool to claude and accepts codex", () => { + const a = lanes.createLane({ cwd: "/tmp/tool-default" }); + assert.equal(a.tool, "claude"); + const b = lanes.createLane({ cwd: "/tmp/tool-codex", tool: "codex" }); + assert.equal(b.tool, "codex"); +}); + +it("createLane rejects an unknown tool", () => { + assert.throws(() => lanes.createLane({ cwd: "/tmp/tool-bad", tool: "cursor" }), (err) => err.code === "EBADTOOL"); +}); + +it("updateLane never writes tool (immutable per lane)", () => { + const lane = lanes.createLane({ cwd: "/tmp/tool-immutable", tool: "codex" }); + const after = lanes.updateLane(lane.id, { tool: "claude", title: "x" }); + assert.equal(after.tool, "codex"); + assert.equal(after.title, "x"); +}); +``` + +Note: these tests use the same `lanes` require and temp-DB setup already at the top of that file — do not add a second harness. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npm run test:server -- --test-name-pattern="tool"` +Expected: FAIL — `createLane` ignores `tool`, `a.tool` is `undefined`. + +- [ ] **Step 3: Add the migrations** + +In `server/db.js`, immediately after the `slug` probe block (ends at line 486) add: + +```js +// Migrate: which CLI drives this lane — `claude` (default) or `codex`. Additive +// with a default, so every existing lane keeps today's behavior. A lane's tool +// is immutable (never in PATCHABLE): the tmux pane, the run history and the +// binary probe all key off it, so flipping it under a live pane would describe +// a run that isn't what's actually attached. +try { + db.prepare("SELECT tool FROM lanes LIMIT 1").get(); +} catch { + db.prepare("ALTER TABLE lanes ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run(); +} +try { + db.prepare("SELECT tool FROM dashboard_runs LIMIT 1").get(); +} catch { + db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tool TEXT NOT NULL DEFAULT 'claude'").run(); +} +``` + +- [ ] **Step 4: Validate and persist the tool in `lanes.js`** + +Near `VALID_KINDS`/`validateKind` (around line 92) add: + +```js +const VALID_TOOLS = new Set(["claude", "codex"]); + +function validateTool(tool) { + if (!VALID_TOOLS.has(tool)) { + throw Object.assign(new Error(`unknown tool: ${tool}`), { code: "EBADTOOL" }); + } +} +``` + +In `createLane`, add `tool = "claude"` to the destructured defaults, call `validateTool(tool)` next to `validateKind(kind)`, and extend the INSERT: + +```js + const info = db + .prepare( + "INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, stage_since) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .run( + title, + cwd.replace(/\/+$/, ""), + branch, + pipeline, + kind, + source_repo, + base_branch, + slug, + tool, + nowIso() + ); +``` + +Export `VALID_TOOLS` from the module's `module.exports` block (alongside `createLane`). + +- [ ] **Step 5: Persist the run's tool** + +In `server/lib/dashboard-runs.js`, add `tool` to the INSERT column list and its `@tool` placeholder, add `tool` to `RUN_COLUMNS`, and in `recordRun`: + +```js + tool: handle.tool === "codex" ? "codex" : "claude", +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `npm run test:server` +Expected: PASS, including the three new cases and every existing lane/run test. + +- [ ] **Step 7: Commit** + +```bash +git add server/db.js server/lib/lanes.js server/lib/dashboard-runs.js server/__tests__/lanes-lib.test.js +git commit -m "feat(lanes): record which CLI tool drives a lane" +``` + +--- + +### Task 2: Codex argv in the pty runner + +**Files:** +- Modify: `server/lib/pty-run.js:56-76` (`buildArgv`), `:78-150` (`spawnRun` validation + record), `:155-180` (`publicRun`) +- Test: `server/__tests__/pty-run.test.js` + +**Interfaces:** +- Consumes: `lanesLib.VALID_TOOLS` (Task 1) — not imported here; `spawnRun` validates against its own local constant to keep `pty-run.js` free of a lanes dependency. +- Produces: `spawnRun({laneId, cwd, tool, model, permissionMode, effort, sandbox, approval, resumeSessionId, initialPrompt})`. `getRun`/`listRuns` handles gain `tool: "claude" | "codex"`. + +- [ ] **Step 1: Write the failing tests** + +Append to `server/__tests__/pty-run.test.js` inside the existing `describe("pty-run", ...)`: + +```js +function captureNewSession(fn) { + 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 ""; + }); + fn(); + return calls.find((c) => c[0] === "new-session"); +} + +it("spawnRun builds a codex argv for a codex lane", () => { + const call = captureNewSession(() => + pty.spawnRun({ + laneId: 7, + cwd: "/tmp/repo", + tool: "codex", + model: "gpt-5.1-codex", + sandbox: "workspace-write", + approval: "on-request", + effort: "high", + initialPrompt: "hello", + }) + ); + const argv = call.slice(6); + assert.deepEqual(argv, [ + "codex", + "-m", + "gpt-5.1-codex", + "-s", + "workspace-write", + "-a", + "on-request", + "-c", + "model_reasoning_effort=high", + "hello", + ]); +}); + +it("spawnRun puts codex resume first, as a subcommand", () => { + const withId = captureNewSession(() => + pty.spawnRun({ + laneId: 8, + cwd: "/tmp/repo", + tool: "codex", + resumeSessionId: "0199aaaa-bbbb-cccc-dddd-eeeeffff0000", + }) + ); + assert.deepEqual(withId.slice(6, 9), [ + "codex", + "resume", + "0199aaaa-bbbb-cccc-dddd-eeeeffff0000", + ]); + + tmux.__reset(); + const last = captureNewSession(() => + pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", tool: "codex", resumeLast: true }) + ); + assert.deepEqual(last.slice(6, 9), ["codex", "resume", "--last"]); +}); + +it("spawnRun rejects a codex sandbox/approval outside the allowlist", () => { + tmux.__setExecImpl(() => ""); + assert.throws( + () => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", sandbox: "rm -rf" }), + (err) => err.code === "EBADSANDBOX" + ); + assert.throws( + () => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", approval: "always" }), + (err) => err.code === "EBADAPPROVAL" + ); +}); + +it("spawnRun never emits codex bypass flags", () => { + const call = captureNewSession(() => + pty.spawnRun({ laneId: 11, cwd: "/tmp/repo", tool: "codex", sandbox: "danger-full-access" }) + ); + assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass"))); +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npm run test:server -- --test-name-pattern="codex"` +Expected: FAIL — argv still starts with `claude`. + +- [ ] **Step 3: Implement the branch** + +In `server/lib/pty-run.js`, next to the existing constants: + +```js +const VALID_TOOLS = new Set(["claude", "codex"]); +const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]); +const CODEX_APPROVALS = new Set(["on-request", "never"]); +// Codex has no `--effort` flag; reasoning effort is a config override. +const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]); +const CODEX_SESSION_RE = /^[0-9a-fA-F-]{8,}$/; +``` + +Rename the existing builder to `buildClaudeArgv` (body unchanged) and add: + +```js +/** + * Codex's CLI shape differs from Claude's in two ways that matter here: + * `resume` is a SUBCOMMAND (so it precedes every flag) and reasoning effort has + * no flag of its own — it rides on a `-c` config override. The prompt stays a + * trailing positional, same as Claude, so the pane stays interactive. + */ +function buildCodexArgv({ model, effort, sandbox, approval, resumeSessionId, resumeLast, initialPrompt }) { + const argv = ["codex"]; + if (resumeSessionId) argv.push("resume", resumeSessionId); + else if (resumeLast) argv.push("resume", "--last"); + if (model) argv.push("-m", model); + if (sandbox) argv.push("-s", sandbox); + if (approval) argv.push("-a", approval); + if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`); + if (initialPrompt) argv.push(initialPrompt); + return argv; +} + +function buildArgv(args) { + return args.tool === "codex" ? buildCodexArgv(args) : buildClaudeArgv(args); +} +``` + +In `spawnRun`, destructure `tool = "claude"`, `sandbox`, `approval`, `resumeLast`, and validate before building: + +```js + if (!VALID_TOOLS.has(tool)) { + throw makeErr("EBADTOOL", `tool must be one of: ${Array.from(VALID_TOOLS).join(", ")}`); + } + if (tool === "codex") { + if (sandbox != null && sandbox !== "" && !CODEX_SANDBOXES.has(sandbox)) { + throw makeErr("EBADSANDBOX", `sandbox must be one of: ${Array.from(CODEX_SANDBOXES).join(", ")}`); + } + if (approval != null && approval !== "" && !CODEX_APPROVALS.has(approval)) { + throw makeErr("EBADAPPROVAL", `approval must be one of: ${Array.from(CODEX_APPROVALS).join(", ")}`); + } + } +``` + +The existing `resumeSessionId` regex (`/^[A-Za-z0-9-]{8,}$/`) already accepts a Codex UUID, so leave it. Pass `tool`, `sandbox`, `approval`, `resumeLast` into `buildArgv`, add `tool` to the `dashboardRuns.recordRun({...})` call, and add `tool: row?.tool || "claude"` to the object `publicRun` returns. + +Also update the `@file` header: the runner now spawns `claude` **or** `codex`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npm run test:server` +Expected: PASS — the new codex cases plus every existing claude argv assertion unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add server/lib/pty-run.js server/__tests__/pty-run.test.js +git commit -m "feat(run): spawn codex in a lane's tmux pane" +``` + +--- + +### Task 3: Routes — create with a tool, start with it, probe its binary + +**Files:** +- Modify: `server/routes/lanes.js:256-294` (`POST /ensure`), `:642-712` (`POST /worktree`), `:1211-1240` (the `start` action) +- Modify: `server/routes/run.js:238-252` (`GET /binary`), `:257-292` (`POST /`) +- Test: `server/__tests__/lanes-api.test.js`, `server/__tests__/run.test.js` + +**Interfaces:** +- Consumes: `lanesLib.VALID_TOOLS`, `lanesLib.createLane({tool})` (Task 1); `runs.spawnRun({tool, sandbox, approval, resumeLast})` (Task 2). +- Produces: `POST /api/lanes/ensure` and `/api/lanes/worktree` accept `tool`; `POST /api/lanes/:id/start` accepts `sandbox`, `approval`, `resumeLast`; `GET /api/run/binary?tool=codex` → `{found, path}`. + +- [ ] **Step 1: Write the failing tests** + +In `server/__tests__/lanes-api.test.js` (reusing that file's existing app/request helper): + +```js +it("POST /api/lanes/ensure stores the requested tool", async () => { + const res = await request(app).post("/api/lanes/ensure").send({ cwd: "/tmp/codex-lane", tool: "codex" }); + assert.equal(res.status, 201); + assert.equal(res.body.lane.tool, "codex"); +}); + +it("POST /api/lanes/ensure rejects an unknown tool", async () => { + const res = await request(app).post("/api/lanes/ensure").send({ cwd: "/tmp/bad-tool", tool: "cursor" }); + assert.equal(res.status, 400); + assert.equal(res.body.error.code, "EBADTOOL"); +}); +``` + +In `server/__tests__/run.test.js`: + +```js +it("GET /api/run/binary?tool=codex probes codex, not claude", async () => { + const res = await request(app).get("/api/run/binary?tool=codex"); + assert.equal(res.status, 200); + assert.equal(typeof res.body.found, "boolean"); +}); + +it("GET /api/run/binary rejects an unknown tool", async () => { + const res = await request(app).get("/api/run/binary?tool=rm"); + assert.equal(res.status, 400); +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npm run test:server -- --test-name-pattern="tool"` +Expected: FAIL — `lane.tool` is `"claude"`, and `/binary?tool=rm` answers 200. + +- [ ] **Step 3: Wire the create routes** + +In `POST /ensure`, before `createLane`: + +```js + if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) { + return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); + } +``` + +and pass `...(body.tool ? { tool: body.tool } : {})` into `createLane` — matching the `pipeline` spread already there. Do the same guard and spread in `POST /worktree`. `EBADTOOL` must also be added to the `err.code === "EBADCWD" || err.code === "EBADPIPELINE"` 400 branch in `/ensure`. + +- [ ] **Step 4: Wire the start action** + +In `server/routes/lanes.js`, inside `case "start"`, the spawn call becomes: + +```js + const handle = runs.spawnRun({ + laneId: current.id, + initialPrompt: body.prompt || "", + cwd: current.cwd, + // The tool is the LANE's fact, never the request's: a client that + // asked for the other CLI would otherwise get a pane that doesn't + // match the lane's badge, its history, or its binary probe. + tool: current.tool || "claude", + model: body.model, + permissionMode: body.permissionMode, + effort: body.effort, + sandbox: body.sandbox, + approval: body.approval, + resumeLast: body.resumeLast === true, + resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined), + }); +``` + +`spawnRun` already ignores the fields that do not belong to the chosen tool, so no extra filtering is needed here. + +- [ ] **Step 5: Wire the binary probe and POST /api/run** + +In `server/routes/run.js`, replace the `GET /binary` handler body with: + +```js +router.get("/binary", (req, res) => { + // Surface whether the lane's CLI is on PATH so the UI can show a helpful + // error before the user clicks Run. We don't actually invoke it. The name is + // taken from a fixed allowlist, never from the query string directly — this + // string reaches `which`. + const tool = req.query.tool === undefined ? "claude" : String(req.query.tool); + if (tool !== "claude" && tool !== "codex") { + return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); + } + const which = require("node:child_process").spawnSync( + process.platform === "win32" ? "where" : "which", + [tool], + { encoding: "utf8" } + ); + const stdout = (which.stdout || "").trim(); + res.json({ found: which.status === 0 && stdout.length > 0, path: stdout || null }); +}); +``` + +In `POST /` (the direct run endpoint), read the tool from the lane row so it agrees with the lane action path: + +```js + const lane = lanesLib.getLane(laneId); + const handle = runs.spawnRun({ + laneId, + cwd, + tool: lane?.tool || "claude", + ... +``` + +adding `const lanesLib = require("../lib/lanes");` to that file's requires if it isn't there already. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `npm run test:server` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/routes/lanes.js server/routes/run.js server/__tests__/lanes-api.test.js server/__tests__/run.test.js +git commit -m "feat(api): accept a lane tool on create, start and binary probe" +``` + +--- + +### Task 4: Client API surface and types + +**Files:** +- Modify: `client/src/lib/types.ts:2284-2330` (`Lane`) +- Modify: `client/src/lib/api.ts:1572-1592` (run client), `:1897-1965` (lanes client), `:2593-2625` (`RunStartArgs`, `RunHandle`) +- Test: covered by Tasks 5-6 (this task changes types and one-line request builders only) + +**Interfaces:** +- Consumes: the routes from Task 3. +- Produces: + - `type LaneTool = "claude" | "codex"`, `Lane.tool: LaneTool`. + - `RunStartArgs` gains `sandbox?: CodexSandbox`, `approval?: CodexApproval`, `resumeLast?: boolean`. + - `type CodexSandbox = "read-only" | "workspace-write" | "danger-full-access"`, `type CodexApproval = "on-request" | "never"`. + - `api.run.binary(tool?: LaneTool)`, `api.lanes.ensure({..., tool})`, `api.lanes.worktree({..., tool})`. + - `RunHandle.tool: LaneTool`, `DashboardRunHistoryItem.tool: LaneTool`. + +- [ ] **Step 1: Add the types** + +In `client/src/lib/types.ts`, above `export interface Lane`: + +```ts +/** Which CLI drives a lane's pane. Fixed at lane creation and never patched. */ +export type LaneTool = "claude" | "codex"; +``` + +and inside `Lane`, next to `kind`: + +```ts + /** The CLI this lane's runs spawn. Immutable once the lane exists. */ + tool: LaneTool; +``` + +- [ ] **Step 2: Extend the API client** + +In `client/src/lib/api.ts`: + +```ts +export type CodexSandbox = "read-only" | "workspace-write" | "danger-full-access"; +export type CodexApproval = "on-request" | "never"; +``` + +`RunStartArgs` gains: + +```ts + /** Codex only: `-s`. Ignored by a claude lane. */ + sandbox?: CodexSandbox; + /** Codex only: `-a`. Ignored by a claude lane. */ + approval?: CodexApproval; + /** Codex only: `codex resume --last` instead of picking a session id. */ + resumeLast?: boolean; +``` + +`RunHandle` gains `tool: LaneTool;`. `DashboardRunHistoryItem` gains `tool: LaneTool;`. + +`binary` becomes: + +```ts + /** GET /api/run/binary - whether the lane's CLI (`claude` or `codex`) was found on PATH. */ + binary: (tool: LaneTool = "claude") => + request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`), +``` + +`ensure` and `worktree` bodies each gain `tool?: LaneTool`. + +- [ ] **Step 3: Typecheck** + +Run: `cd client && npx tsc --noEmit` +Expected: PASS (the components consuming these props are updated in Tasks 5-6; if `tsc` flags a missing `tool` in a test fixture Lane object, add `tool: "claude"` there). + +- [ ] **Step 4: Commit** + +```bash +git add client/src/lib/types.ts client/src/lib/api.ts +git commit -m "feat(client): type the lane tool and codex run args" +``` + +--- + +### Task 5: Pick the tool when adding a lane + +**Files:** +- Modify: `client/src/components/lanes/AddLaneModal.tsx:44-70` (`Seg`, reused), `:88-120` (state + `reset`), `:178-210` (`submit`), the render block around `:290-320` +- Modify: `client/src/i18n/locales/en/lanes.json` and the matching `vi` file (same keys) +- Test: `client/src/components/lanes/__tests__/AddLaneModal.test.tsx` + +**Interfaces:** +- Consumes: `api.lanes.ensure({tool})`, `api.lanes.worktree({tool})`, `LaneTool` (Task 4). +- Produces: nothing other components import. + +- [ ] **Step 1: Write the failing test** + +Add to `client/src/components/lanes/__tests__/AddLaneModal.test.tsx`, following that file's existing render helper and API mock: + +```tsx +it("sends the picked tool when adopting a repo", async () => { + const user = userEvent.setup(); + renderModal(); + await user.click(screen.getByRole("button", { name: /repo/i })); + await user.type(screen.getByLabelText(/path|repo/i), "/tmp/demo"); + await user.click(screen.getByRole("button", { name: /codex/i })); + await user.click(screen.getByRole("button", { name: /add lane|create/i })); + expect(ensureMock).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" })); +}); + +it("defaults to claude code", async () => { + const user = userEvent.setup(); + renderModal(); + await user.click(screen.getByRole("button", { name: /repo/i })); + await user.type(screen.getByLabelText(/path|repo/i), "/tmp/demo"); + await user.click(screen.getByRole("button", { name: /add lane|create/i })); + expect(ensureMock).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" })); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cd client && npx vitest run src/components/lanes/__tests__/AddLaneModal.test.tsx` +Expected: FAIL — no Codex button exists. + +- [ ] **Step 3: Implement the toggle** + +State and reset: + +```tsx + const [tool, setTool] = useState("claude"); +``` +```tsx + setTool("claude"); +``` + +Render, directly under the Repo/Worktree segmented control (reusing the local `Seg`): + +```tsx +
+ setTool("claude")} /> + setTool("codex")} /> +
+``` + +Both API calls in `submit` take `tool`: + +```tsx + const result = await api.lanes.ensure({ cwd: repo, title: name || undefined, pipeline, tool }); +``` +```tsx + const result = await api.lanes.worktree({ sourceRepo: repo, title: name, base: base || undefined, branch: branch.trim(), pipeline, tool }); +``` + +i18n keys (`en`, then the same keys in `vi`): + +```json + "addLaneToolClaude": "Claude Code", + "addLaneToolCodex": "Codex", + "addLaneToolHint": "Which CLI drives this lane. Cannot be changed later." +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd client && npx vitest run src/components/lanes/__tests__/AddLaneModal.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add client/src/components/lanes/AddLaneModal.tsx client/src/components/lanes/__tests__/AddLaneModal.test.tsx client/src/i18n +git commit -m "feat(lanes): pick Claude Code or Codex when adding a lane" +``` + +--- + +### Task 6: Per-tool run setup and binary probe + +**Files:** +- Modify: `client/src/components/run/RunSetup.tsx:94-121` (props), `:196-245` (advanced fields), `:281-300` (`handleStart`) +- Modify: `client/src/components/run/LaneConsolePane.tsx:36-52` (props), `:70-80` (state), `:197-250` (`onStartFromSetup`), `:444-460` (RunSetup render) +- Modify: `client/src/pages/Workspace.tsx` (the binary-probe fetch that feeds `binaryStatus`) +- Modify: `client/src/i18n/locales/{en,vi}/run.json` +- Test: `client/src/components/run/__tests__/RunSetup.test.tsx` + +**Interfaces:** +- Consumes: `LaneTool`, `CodexSandbox`, `CodexApproval`, `api.run.binary(tool)` (Task 4); `api.lanes.action(id, "start", {sandbox, approval, resumeLast})` (Task 3). +- Produces: `RunSetupProps` gains `tool: LaneTool`, `sandbox: CodexSandbox`, `onSandboxChange`, `approval: CodexApproval`, `onApprovalChange`, `onResumeLast: () => void`. + +- [ ] **Step 1: Write the failing tests** + +In `client/src/components/run/__tests__/RunSetup.test.tsx`, following that file's existing `renderSetup` helper (extend it to take a `tool` override): + +```tsx +it("shows sandbox and approval instead of permission mode for a codex lane", () => { + renderSetup({ tool: "codex" }); + expect(screen.getByText(/sandbox/i)).toBeInTheDocument(); + expect(screen.getByText(/approval/i)).toBeInTheDocument(); + expect(screen.queryByText(/permission mode/i)).not.toBeInTheDocument(); +}); + +it("keeps permission mode for a claude lane", () => { + renderSetup({ tool: "claude" }); + expect(screen.getByText(/permission mode/i)).toBeInTheDocument(); + expect(screen.queryByText(/sandbox/i)).not.toBeInTheDocument(); +}); + +it("sends codex fields on start", async () => { + const onStart = vi.fn(); + const user = userEvent.setup(); + renderSetup({ tool: "codex", onStart, prompt: "go" }); + await user.click(screen.getByRole("button", { name: /start/i })); + expect(onStart).toHaveBeenCalledWith( + expect.objectContaining({ sandbox: "workspace-write", approval: "on-request" }) + ); + expect(onStart.mock.calls[0][0].permissionMode).toBeUndefined(); +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cd client && npx vitest run src/components/run/__tests__/RunSetup.test.tsx` +Expected: FAIL — `tool` is not a prop and the Codex fields don't render. + +- [ ] **Step 3: Implement the per-tool fields** + +`RunSetupProps` gains: + +```tsx + /** The selected lane's CLI. Decides which advanced fields render. */ + tool: LaneTool; + sandbox: CodexSandbox; + onSandboxChange: (s: CodexSandbox) => void; + approval: CodexApproval; + onApprovalChange: (a: CodexApproval) => void; + /** Codex lanes resume with `--last`: the sessions table holds Claude + * sessions only, so there is nothing for the picker to list. */ + onResumeLast: () => void; +``` + +In the advanced-fields grid, replace the two Claude-only `Field`s with a branch: + +```tsx + {props.tool === "codex" ? ( + <> + + + value={props.sandbox} + onChange={props.onSandboxChange} + options={[ + { value: "read-only", label: t("fields.sandboxReadOnly") }, + { value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") }, + { value: "danger-full-access", label: t("fields.sandboxFullAccess") }, + ]} + /> + + + + value={props.approval} + onChange={props.onApprovalChange} + options={[ + { value: "on-request", label: t("fields.approvalOnRequest") }, + { value: "never", label: t("fields.approvalNever") }, + ]} + /> + + + ) : ( + + {/* the existing permission-mode Select, moved verbatim */} + + )} +``` + +The effort `Field` stays for both tools (Codex maps it to `model_reasoning_effort`). Guard the bypass warning with `props.tool === "claude" &&`. Add a `danger-full-access` warning reusing the same banner markup and the new key `hint.sandboxWarning`. + +In the resume header, render the Claude `SessionPicker` only when `props.tool === "claude"`; for `codex`, render a single button calling `props.onResumeLast()`. + +`handleStart` sends only what the tool uses: + +```tsx +function handleStart(props: RunSetupProps, session?: Session) { + const resume = session ?? props.resumeSession; + const codex = props.tool === "codex"; + props.onStart({ + laneId: props.laneId, + cwd: resume?.cwd || props.cwd || undefined, + model: props.model || undefined, + permissionMode: codex ? undefined : props.permissionMode || undefined, + sandbox: codex ? props.sandbox : undefined, + approval: codex ? props.approval : undefined, + effort: props.effort || undefined, + resumeSessionId: codex ? undefined : resume?.id || undefined, + initialPrompt: props.prompt || undefined, + }); +} +``` + +In `LaneConsolePane`, add the state and pass it through: + +```tsx + const [sandbox, setSandbox] = useState("workspace-write"); + const [approval, setApproval] = useState("on-request"); + const laneTool = lanes.find((l) => l.id === laneId)?.tool ?? "claude"; +``` + +The `api.lanes.action(targetLaneId, "start", {...})` call gains `sandbox: args.sandbox, approval: args.approval, resumeLast: args.resumeLast`. The `RunSetup` render gains `tool={laneTool}`, the four sandbox/approval props, and `onResumeLast={() => onStartFromSetup({ laneId, cwd, resumeLast: true })}`. `binaryFound` becomes `binaryStatus?.[laneTool]?.found ?? true`. + +In `Workspace.tsx`, fetch both probes once and keep the same prop name: + +```tsx + Promise.all([api.run.binary("claude"), api.run.binary("codex")]) + .then(([claude, codex]) => setBinaryStatus({ claude, codex })) + .catch(() => setBinaryStatus(null)); +``` + +updating `binaryStatus`'s type to `Record | null` in both files. + +i18n (`en` then `vi`, `run.json`): + +```json + "fields.sandbox": "Sandbox", + "fields.sandboxReadOnly": "Read only", + "fields.sandboxWorkspaceWrite": "Workspace write", + "fields.sandboxFullAccess": "Full access (dangerous)", + "fields.approval": "Approval", + "fields.approvalOnRequest": "On request", + "fields.approvalNever": "Never", + "hint.sandboxWarning": "Codex will run commands with no sandbox.", + "resume.lastOption": "Resume last session" +``` + +(Use the nested shape the file already uses — these are shown flat only for brevity.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd client && npx vitest run src/components/run src/pages` +Expected: PASS. Screen snapshots may fail — review the diff, confirm it is only the new Codex fields, then regenerate with `cd client && npx vitest run -u`. + +- [ ] **Step 5: Commit** + +```bash +git add client/src/components/run client/src/pages/Workspace.tsx client/src/i18n +git commit -m "feat(run): per-tool run setup fields and binary probe" +``` + +--- + +### Task 7: Lane badge, docs, and full verification + +**Files:** +- Modify: `client/src/components/lanes/LaneCard.tsx`, `client/src/components/lanes/LaneStripCard.tsx` +- Modify: `docs/LANES.md`, `docs/API.md` +- Test: `client/src/components/lanes/__tests__/LaneCard.test.tsx` + +**Interfaces:** +- Consumes: `Lane.tool` (Task 4). +- Produces: nothing. + +- [ ] **Step 1: Write the failing test** + +In `client/src/components/lanes/__tests__/LaneCard.test.tsx`, using that file's existing lane fixture helper: + +```tsx +it("badges a codex lane and leaves a claude lane unbadged", () => { + const { rerender } = render(); + expect(screen.getByText("Codex")).toBeInTheDocument(); + rerender(); + expect(screen.queryByText("Codex")).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cd client && npx vitest run src/components/lanes/__tests__/LaneCard.test.tsx` +Expected: FAIL — no badge rendered. + +- [ ] **Step 3: Add the badge** + +In both `LaneCard.tsx` and `LaneStripCard.tsx`, next to the existing branch/kind chips: + +```tsx + {lane.tool === "codex" && ( + + Codex + + )} +``` + +Claude lanes render nothing new, so existing lane cards are pixel-identical. + +- [ ] **Step 4: Update the docs** + +In `docs/LANES.md`, add a "Lane tool" section stating: a lane is driven by `claude` (default) or `codex`, chosen in Add lane and immutable afterwards; the Codex pane maps model/sandbox/approval/reasoning-effort onto `codex -m/-s/-a/-c model_reasoning_effort=`; resume for a Codex lane is `codex resume --last`; and the known limitations — Codex emits no Claude Code hooks, so Sessions, Analytics, cost and `stage-detect` stay empty for a Codex lane, `ccam stage` still works because the agent calls it itself, and liveness comes from the tmux pane plus `dashboard_runs`. + +In `docs/API.md`, document `tool` on the lane payload and on run history, the new `tool` body field for `POST /api/lanes/ensure` and `POST /api/lanes/worktree` (400 `EBADTOOL`), the `sandbox`/`approval`/`resumeLast` body fields on `POST /api/lanes/:id/start`, and the `?tool=` query on `GET /api/run/binary`. + +- [ ] **Step 5: Run every gate** + +Run, in order: + +```bash +npm run test:server +npm run test:client +bash skills/file-headers/scripts/check-headers.sh +``` + +Expected: all three exit 0. No MCP source changed, so `mcp:*` gates are not required. + +- [ ] **Step 6: Commit** + +```bash +git add client/src/components/lanes docs/LANES.md docs/API.md +git commit -m "feat(lanes): badge codex lanes and document the lane tool" +``` + +--- + +## Self-Review + +- **Spec coverage:** data model → Task 1; spawn/argv → Task 2; API → Task 3; client types → Task 4; Add-lane picker → Task 5; RunSetup fields, resume-last, binary probe → Task 6; badge, limitations doc, verification → Task 7. +- **Placeholders:** none — every step names files, code, and the command to run. +- **Type consistency:** `LaneTool`, `CodexSandbox`, `CodexApproval` are defined in Task 4 and used unchanged in Tasks 5-7; `spawnRun`'s `tool`/`sandbox`/`approval`/`resumeLast` names match between Tasks 2, 3 and 6; `EBADTOOL`/`EBADSANDBOX`/`EBADAPPROVAL` codes match between Tasks 1, 2 and 3. diff --git a/docs/superpowers/specs/2026-08-26-workspace-codex-lane-design.md b/docs/superpowers/specs/2026-08-26-workspace-codex-lane-design.md new file mode 100644 index 00000000..accbaf5c --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-workspace-codex-lane-design.md @@ -0,0 +1,112 @@ +# Codex lanes in the Workspace — design + +Issue: [Smartgift-AI/Claude-Code-Monitor#10](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/issues/10) +— "Workspace | Thêm Codex vào trang Workspace". + +## Goal + +A lane can be driven by the `codex` CLI instead of `claude`, started and watched +from the Workspace page exactly like today: same lane strip, same pipeline map, +same tmux-backed terminal console, same run history. + +## Decisions (from brainstorming) + +1. Scope: run Codex in the lane's tmux pane. No ingestion of `~/.codex` history. +2. The tool belongs to the **lane**, not to the run: one lane = one tool, picked + in the "Add lane" modal, not changeable afterwards. One tmux session per lane + stays `ccam-lane-`. +3. Codex run controls: model, sandbox, ask-for-approval, reasoning effort. +4. Codex runs persist in `dashboard_runs` (new `tool` column) so Active runs and + Run history show them next to Claude runs. +5. The rest of the Workspace UI is unchanged. + +## Data model + +`server/db.js`, following the existing `ALTER TABLE lanes ADD COLUMN` pattern +(idempotent, try/catch on "duplicate column"): + +- `lanes.tool TEXT NOT NULL DEFAULT 'claude'` — `'claude' | 'codex'`. +- `dashboard_runs.tool TEXT NOT NULL DEFAULT 'claude'`. + +Both default to `'claude'`, so every existing row keeps today's behavior and no +response shape changes for existing consumers — `tool` is an added field. + +`server/lib/lanes.js` accepts `tool` on create (`ensureLane`, worktree create), +validates it against `{claude, codex}`, and exposes it on the lane payload. +`updateLane` never writes `tool` — a lane's tool is immutable, same spirit as +"adopted lanes are not destroyable". + +## Spawn + +`server/lib/pty-run.js` — `buildArgv({tool, ...})` branches: + +``` +claude (unchanged): + claude --permission-mode --allow-dangerously-skip-permissions + [--model M] [--effort E] [--resume SID] [prompt] + +codex: + codex [resume | resume --last] + [-m MODEL] [-s SANDBOX] [-a APPROVAL] + [-c model_reasoning_effort=EFFORT] [prompt] +``` + +Notes: + +- `resume` is a **subcommand** for Codex, so it comes first, before the flags. + `codex resume [prompt]`; with no recorded id, `codex resume --last`. +- Allowlists (reject with `EBAD*` like today, never pass through): + - sandbox: `read-only | workspace-write | danger-full-access` + - approval: `on-request | never` + - effort: `low | medium | high | xhigh` (Codex has no `--effort` flag; it is + passed as `-c model_reasoning_effort=`) +- The `--dangerously-bypass-approvals-and-sandbox` and + `--dangerously-bypass-hook-trust` flags are never emitted. +- `spawnRun` takes `tool` and records it on the run row. tmux session naming, + create-or-adopt, and the shell-prompt send path are untouched. + +## API + +- `POST /api/lanes/ensure`, `POST /api/lanes/worktree`: accept optional `tool`. +- `POST /api/lanes/:id/start`: accepts `sandbox` and `approval` in addition to + today's `model`/`effort`/`permissionMode`/`resume`. The **server reads the tool + from the lane row**, never from the request body, and ignores the fields that + do not belong to that tool. +- `GET /api/run/binary?tool=codex`: probes `codex` on PATH (`which`), same + `{found, path}` shape. Default (no query) stays `claude`. + +## UI + +- `AddLaneModal`: a second segmented toggle "Claude Code | Codex" using the + existing `Seg` component, next to the Repo/Worktree toggle. +- `LaneCard` / `LaneStripCard`: a small tool badge, shown only for `codex` lanes + so Claude lanes look exactly as before. +- `RunSetup`: fields switch on the selected lane's `tool`. + - claude: model, permission mode, effort (unchanged) + - codex: model, sandbox, approval, reasoning effort + - Codex lanes hide the Claude session picker (the sessions table holds Claude + sessions only) and offer a single "Resume last" action mapping to + `codex resume --last`. + - The Start button's disabled state uses the per-tool binary probe. +- Console, pipeline map, split view, run history: unchanged. +- All new strings go through i18n, no literals. + +## Known limitations (documented, not fixed here) + +Codex does not emit this dashboard's Claude Code hooks, so for a Codex lane: + +- Sessions, Analytics, and cost stay empty — nothing ingests Codex usage. +- `server/lib/stage-detect.js` never paints the amber detected badge. +- Pipeline stages still work when the agent calls `ccam stage` itself (the repo + already ships `AGENTS.md` and `.codex/`). +- Lane liveness for Codex lanes comes from the tmux pane plus `dashboard_runs`. + +## Testing + +- `npm run test:server`: Codex argv shape (fresh, resume-by-id, resume-last, + effort mapping), allowlist rejections, `tool` persisted on lane and run, + start route ignoring foreign fields. +- `npm run test:client`: `RunSetup` field set per tool, `AddLaneModal` tool + choice reaching the API call, screen snapshots reviewed and regenerated. +- Docs to update: `docs/LANES.md` (tool per lane, Codex limitations), + `docs/API.md` (new fields), `README` lane section if it lists lane fields.