# 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 `agenthub-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, `agenthub 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.