33 KiB
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-<id>, 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ĩ <vinnt@smartgift.vn>). Verify withbash 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-sandboxor--dangerously-bypass-hook-trust. - A lane's
toolis immutable after creation: never added toPATCHABLE. - 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 theslugprobe, before thelane_idprobe 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 withtool: "claude" | "codex"; throwsErrorwithcode === "EBADTOOL"on anything else.lanesLib.VALID_TOOLS—Set<string>exported for route validation.dashboardRuns.recordRun({..., tool})persistsdashboard_runs.tool;listRuns/getRunrows carrytool.
-
Step 1: Write the failing tests
In server/__tests__/lanes-lib.test.js, inside the existing top-level describe, add:
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:
// 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:
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:
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:
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
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(spawnRunvalidation + record),:155-180(publicRun) - Test:
server/__tests__/pty-run.test.js
Interfaces:
-
Consumes:
lanesLib.VALID_TOOLS(Task 1) — not imported here;spawnRunvalidates against its own local constant to keeppty-run.jsfree of a lanes dependency. -
Produces:
spawnRun({laneId, cwd, tool, model, permissionMode, effort, sandbox, approval, resumeSessionId, initialPrompt}).getRun/listRunshandles gaintool: "claude" | "codex". -
Step 1: Write the failing tests
Append to server/__tests__/pty-run.test.js inside the existing describe("pty-run", ...):
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:
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:
/**
* 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:
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
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(thestartaction) - 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/ensureand/api/lanes/worktreeaccepttool;POST /api/lanes/:id/startacceptssandbox,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):
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:
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:
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:
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:
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:
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
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.RunStartArgsgainssandbox?: 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:
/** 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:
/** 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:
export type CodexSandbox = "read-only" | "workspace-write" | "danger-full-access";
export type CodexApproval = "on-request" | "never";
RunStartArgs gains:
/** 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:
/** 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
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.jsonand the matchingvifile (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:
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:
const [tool, setTool] = useState<LaneTool>("claude");
setTool("claude");
Render, directly under the Repo/Worktree segmented control (reusing the local Seg):
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg active={tool === "claude"} label={t("addLaneToolClaude")} onClick={() => setTool("claude")} />
<Seg active={tool === "codex"} label={t("addLaneToolCodex")} onClick={() => setTool("codex")} />
</div>
Both API calls in submit take tool:
const result = await api.lanes.ensure({ cwd: repo, title: name || undefined, pipeline, tool });
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):
"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
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 feedsbinaryStatus) - 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:
RunSetupPropsgainstool: 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):
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:
/** 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 Fields with a branch:
{props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
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") },
]}
/>
</Field>
<Field label={t("fields.approval")}>
<Select<CodexApproval>
value={props.approval}
onChange={props.onApprovalChange}
options={[
{ value: "on-request", label: t("fields.approvalOnRequest") },
{ value: "never", label: t("fields.approvalNever") },
]}
/>
</Field>
</>
) : (
<Field label={t("fields.permissionMode")}>
{/* the existing permission-mode Select, moved verbatim */}
</Field>
)}
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:
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:
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("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:
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<LaneTool, {found: boolean; path: string | null}> | null in both files.
i18n (en then vi, run.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
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:
it("badges a codex lane and leaves a claude lane unbadged", () => {
const { rerender } = render(<LaneCard {...baseProps} lane={makeLane({ tool: "codex" })} />);
expect(screen.getByText("Codex")).toBeInTheDocument();
rerender(<LaneCard {...baseProps} lane={makeLane({ tool: "claude" })} />);
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:
{lane.tool === "codex" && (
<span className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
</span>
)}
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:
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
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,CodexApprovalare defined in Task 4 and used unchanged in Tasks 5-7;spawnRun'stool/sandbox/approval/resumeLastnames match between Tasks 2, 3 and 6;EBADTOOL/EBADSANDBOX/EBADAPPROVALcodes match between Tasks 1, 2 and 3.