docs: spec and plan for codex lanes
This commit is contained in:
@@ -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-<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 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<string>` 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<LaneTool>("claude");
|
||||||
|
```
|
||||||
|
```tsx
|
||||||
|
setTool("claude");
|
||||||
|
```
|
||||||
|
|
||||||
|
Render, directly under the Repo/Worktree segmented control (reusing the local `Seg`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<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`:
|
||||||
|
|
||||||
|
```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" ? (
|
||||||
|
<>
|
||||||
|
<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:
|
||||||
|
|
||||||
|
```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<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:
|
||||||
|
|
||||||
|
```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<LaneTool, {found: boolean; path: string | null}> | 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(<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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{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:
|
||||||
|
|
||||||
|
```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.
|
||||||
@@ -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-<id>`.
|
||||||
|
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 <mode> --allow-dangerously-skip-permissions
|
||||||
|
[--model M] [--effort E] [--resume SID] [prompt]
|
||||||
|
|
||||||
|
codex:
|
||||||
|
codex [resume <uuid> | 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 <uuid> [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=<value>`)
|
||||||
|
- 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.
|
||||||
Reference in New Issue
Block a user