fix(lanes): auto-detect and report stage for codex lanes (issue 11)

A lane on the 16-node `ship-feature` template showed an untouched pipeline
map and never declared a stage. Four independent causes, fixed at their
shared choke points:

1. Codex emits no hooks, so `touchLaneFromHook` — the only caller of
   `stage-detect.detect()`, and correctly pinned to `claude` — could never
   see a `codex` lane. New `server/lib/codex-ingest.js` tails Codex's own
   rollout journals (`$CODEX_HOME/sessions/**/rollout-*.jsonl`), maps its
   tool calls onto the hook-shaped `{tool_name, tool_input}` the matcher
   already understands, and feeds the existing detect path plus an
   idle/running status mirror under the same guard hooks use. Polled every
   `DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables).
2. `resolveLaneArg` matched on cwd alone, so with a claude and a codex lane
   at one directory (the composite `idx_lanes_cwd_tool`) a `ccam stage`
   call could drive the sibling lane's card. It now resolves by
   `--lane` → `CCAM_LANE_ID` → `--tool`/harness marker → owning lane, and
   errors naming both candidates rather than picking one. Every pane the
   dashboard starts carries `CCAM_LANE_ID` (`tmux new-session -e`, or an
   assignment prefix when typing into a live shell pane).
3. `recordDetection`'s declared-wins veto had no exit: a lane left at the
   pipeline's last node rejected every detection for the rest of its life.
   A declaration on the LAST node no longer vetoes — the pipeline is over
   there, so a later detection is new work. Nothing else is relaxed, and
   detection still never writes `stage` nor renders `done`.
4. The run console pre-filled the pipeline's Claude slash command into
   codex panes, which Codex reads as prose — so the skill that would have
   called `ccam stage` never ran. Claude lanes only now.

A codex lane's *declared* stage still depends on the session calling
`ccam stage`; `AGENTS.md` is the only lever CCAM has, so it now says so.

Verified: server 1345/1345, client 441/441, and end-to-end against a real
codex lane row (detected_stage/detected_signal set, declared stage
untouched, no node rendered done).
This commit is contained in:
2026-08-27 16:38:23 +07:00
parent b1734a492f
commit fd7f57929c
22 changed files with 2343 additions and 35 deletions
@@ -0,0 +1,811 @@
# Issue 11 — Pipeline auto-detect + stage declaration 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 on the 16-node `ship-feature` template shows real progress again — a `codex` lane gets auto-detected stages and a live status, a lane whose declared stage is the pipeline's last node can be detected again, and `ccam stage` from a cwd that carries both a `claude` and a `codex` lane writes to the right one.
**Architecture:** Four independent fixes at their shared choke points. (1) `bin/ccam.js`'s single `resolveLaneArg` learns a tool, sourced from `CCAM_LANE_ID` (injected by `pty-run` into the tmux pane), an explicit `--tool`, or the harness's own env markers — all 15 cwd-resolving commands inherit it. (2) `recordDetection` drops the declared-wins veto only when the declaration sits on the pipeline's LAST node. (3) A new `server/lib/codex-ingest.js` tails Codex's own on-disk rollout JSONL — Codex emits no hooks, so this is the codex analogue of `server/lib/workflow-ingest.js` — maps its tool calls onto hook-shaped `{tool_name, tool_input}` events, and runs the existing `detect()` + `recordDetection()` + idle/running mirror. (4) The run console stops pre-filling a Claude slash command into a codex pane.
**Tech Stack:** Node 20 CommonJS (server, `bin/ccam.js`), `node:test` + `node --test` for server tests, React 19 + Vite + Vitest for the client, tmux 3.4 CLI, SQLite via better-sqlite3.
## Global Constraints
- Every source file created or edited starts with the repo header: file overview + the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. Verify with `bash skills/file-headers/scripts/check-headers.sh` (must exit 0).
- API response shapes and WebSocket message types stay backward-compatible. No new API route is added by this plan.
- No schema change. All new ingest state is in-memory and re-derived (same "computed fact, never a stored one" rule the lane runtime follows).
- Ingestion is fail-safe and non-blocking: a malformed rollout file, an absent `~/.codex`, or a SQLite error is swallowed locally and never breaks a poll cycle or a hook.
- `lanes.tool` stays immutable and absent from `PATCHABLE`. Nothing in this plan writes it.
- The codex ingest writes ONLY `detected_stage`/`detected_signal`/`detected_at` (via `recordDetection`) and — under the same guard hook ingestion uses — `status`. It never writes `stage`, `notes`, `slot`, or `ports`.
- Backend done → `npm run test:server`. Client done → `npm run test:client`. Docs updated in the same change-set (`update-project-docs`), including the `_Last synced:_` marker at the top of `CLAUDE.md`.
- Commit only when the user asks in that turn.
---
### Task 1: Tool-aware lane resolution for the CLI
A cwd can back one `claude` lane AND one `codex` lane (composite `idx_lanes_cwd_tool`). `resolveLaneArg` matches on cwd only and takes the first of the tie, so every cwd-resolved command — `ccam stage`, `ccam lanes pipeline`, `ccam feature activate`, 15 call sites in total — can silently drive the sibling lane's card. Fix it once in the shared function, and make the dashboard-launched pane unambiguous by construction.
**Files:**
- Modify: `bin/ccam.js:1788-1809` (`resolveLaneArg`)
- Modify: `server/lib/tmux.js:41-44` (`newSession` gains `env`)
- Modify: `server/lib/pty-run.js:184-236` (`spawnRun` passes `CCAM_LANE_ID`)
- Test: `server/__tests__/lanes-cli.test.js` (resolution precedence)
- Test: `server/__tests__/pty-run.test.js` (env injection)
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces:
- `resolveLaneArg(args) -> Promise<{laneId: string|number, rest: string[]}|null>` — unchanged signature, new precedence.
- `tmux.newSession({name, cwd, argv, env})` — `env` is an optional plain object of string→string; omitted/empty behaves exactly as before.
- Env contract: `CCAM_LANE_ID=<lane id>` is present in every pane the dashboard starts.
- [ ] **Step 1: Write the failing CLI-resolution tests**
In `server/__tests__/lanes-cli.test.js`, following that file's existing harness (it already boots the API and shells out to `bin/ccam.js`): create two lanes at the SAME cwd, one `claude` and one `codex`, then assert
1. with `CCAM_LANE_ID` set to the codex lane's id, `ccam stage plan` moves the codex lane and leaves the claude lane untouched;
2. with `--tool codex` and no env, the codex lane moves;
3. with `CLAUDECODE=1` in the environment and no flag, the claude lane moves;
4. with neither a flag nor any env marker (delete `CLAUDECODE` and `CLAUDE_CODE_ENTRYPOINT` from the child env — the test itself runs under Claude Code), the command FAILS (non-zero exit) and its stderr names both candidate lanes — an ambiguous write is worse than no write.
Note for case 2/3: there is deliberately no codex env marker (see the comment in Step 3), so `--tool codex` and `CCAM_LANE_ID` are the only two ways a codex session outside a dashboard pane can identify itself.
- [ ] **Step 2: Run them to verify they fail**
Run: `node --test server/__tests__/lanes-cli.test.js`
Expected: FAIL — cases 1, 2 and 4 write to the claude lane (the first tie-break) instead of the codex lane / instead of erroring.
- [ ] **Step 3: Implement the new precedence in `resolveLaneArg`**
```js
/**
* Resolution order: `--lane`/positional id → `CCAM_LANE_ID` (the dashboard
* injects it into every pane it starts) → `--tool` → the harness's own env
* markers → the lane owning the cwd. The tool matters because a cwd can back
* one `claude` lane AND one `codex` lane (composite idx_lanes_cwd_tool): with
* two candidates and no tool signal we fail loudly instead of driving a coin
* flip — writing a stage onto the sibling tool's card is the bug this order
* exists to prevent.
*/
// Claude Code is the only harness with a reliable marker: it exports
// CLAUDECODE / CLAUDE_CODE_ENTRYPOINT into every command it runs. Codex has
// no equivalent — `strings` on codex 0.149 shows only CODEX_HOME (which it
// READS) and CODEX_SANDBOX* (set only under a sandbox policy), so sniffing for
// a CODEX_ prefix would be a guess. A codex session identifies itself with
// `--tool codex`, or needs nothing at all inside a dashboard-started pane
// (CCAM_LANE_ID above). Absent any signal with two candidates we error.
function toolFromEnv() {
if (process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT) return "claude";
return null;
}
async function resolveLaneArg(args) {
const flagValue = (name) => {
const i = args.indexOf(`--${name}`);
return i > -1 ? args[i + 1] : undefined;
};
const explicit = flagValue("lane");
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
if (process.env.CCAM_LANE_ID && /^\d+$/.test(process.env.CCAM_LANE_ID)) {
return { laneId: process.env.CCAM_LANE_ID, rest: args };
}
const tool = flagValue("tool") || toolFromEnv();
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
const { lanes } = await get("/api/lanes");
const owning = lanes
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.length) {
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
process.exitCode = 1;
return null;
}
// Longest path wins first; among lanes at that same directory the tool picks.
const nearest = owning.filter((l) => l.cwd === owning[0].cwd);
const candidates = tool ? nearest.filter((l) => (l.tool || "claude") === tool) : nearest;
if (!candidates.length) {
console.error(`no ${tool} lane owns ${owning[0].cwd} (found: ${nearest.map((l) => l.tool || "claude").join(", ")})`);
process.exitCode = 1;
return null;
}
if (candidates.length > 1) {
console.error(
`${candidates.length} lanes own ${owning[0].cwd} — pass --tool <${candidates
.map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates.map((l) => `#${l.id} (${l.tool || "claude"})`).join(", ")}`
);
process.exitCode = 1;
return null;
}
return { laneId: candidates[0].id, rest: args };
}
```
Also add `--tool <claude|codex>` to the `usage:` line of `cmdStage` (`bin/ccam.js:2470-2473`) and to the `stage` entry in `COMMAND_GROUPS`.
- [ ] **Step 4: Run the CLI tests to verify they pass**
Run: `node --test server/__tests__/lanes-cli.test.js`
Expected: PASS.
- [ ] **Step 5: Write the failing env-injection test**
In `server/__tests__/pty-run.test.js`, using that file's existing `tmux.__setExecImpl` capture: assert `spawnRun({laneId: 7, cwd: "/tmp/x", tool: "codex"})` produces a `new-session` argv containing `-e` immediately followed by `CCAM_LANE_ID=7`, and that the shell-pane branch (pane command `bash`) first sends `export CCAM_LANE_ID=7` and then the tool argv.
- [ ] **Step 6: Run it to verify it fails**
Run: `node --test server/__tests__/pty-run.test.js`
Expected: FAIL — no `-e` in the argv, single `send-keys` sequence.
- [ ] **Step 7: Implement env injection**
`server/lib/tmux.js`:
```js
/**
* `env` entries become tmux `-e NAME=value` pairs, which tmux puts in the
* pane's own environment (tmux >= 3.2). The dashboard uses it for
* CCAM_LANE_ID so a CLI call from inside the pane resolves its lane exactly
* instead of guessing from cwd — see bin/ccam.js's resolveLaneArg.
*/
function newSession({ name, cwd, argv, env }) {
const envFlags = [];
for (const [k, v] of Object.entries(env || {})) envFlags.push("-e", `${k}=${v}`);
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags, "--", ...argv]);
enableMouse(name);
}
```
`server/lib/pty-run.js` — in `spawnRun`:
```js
if (!tmux.hasSession(id)) {
tmux.newSession({ name: id, cwd, argv, env: { CCAM_LANE_ID: String(laneId) } });
record();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
// tmux's -e only applies at session creation, so an existing shell pane
// gets the variable the only way a live shell can: an export line typed
// ahead of the tool's own argv.
tmux.sendCommand(id, ["export", `CCAM_LANE_ID=${laneId}`]);
tmux.sendCommand(id, argv);
record();
}
```
- [ ] **Step 8: Run both test files to verify they pass**
Run: `node --test server/__tests__/pty-run.test.js server/__tests__/lanes-cli.test.js`
Expected: PASS.
- [ ] **Step 9: Commit**
```bash
git add bin/ccam.js server/lib/tmux.js server/lib/pty-run.js server/__tests__/pty-run.test.js server/__tests__/lanes-cli.test.js
git commit -m "fix(cli): resolve a lane by tool, not by cwd alone"
```
---
### Task 2: A declaration on the pipeline's last node stops vetoing detection
`recordDetection` rejects every detection whose node is at or behind the DECLARED stage, with no expiry — deliberate, an agent's claim outranks an inference. But when the declaration is the pipeline's LAST node the pipeline is over, so a fresh detection can only mean new work: lane 23 sat at `done` and every detection for hours returned `behind-declared`, which reads to the user as "the 16-node pipeline doesn't auto-detect". The exception is narrow on purpose — mid-pipeline declared-wins is untouched, and detection still never writes `stage` nor renders `done`.
**Files:**
- Modify: `server/lib/lanes.js:338-361` (`recordDetection`)
- Test: `server/__tests__/lanes-lib.test.js`
**Interfaces:**
- Consumes: `phaseIdx(pipeline, stage)` and `getPipeline(id)` from `server/lib/pipelines.js` (already imported in `lanes.js`).
- Produces: `recordDetection(id, {nodeId, signal}) -> {written: boolean, reason?: "behind-detected"|"behind-declared"|"unknown-node"}` — unchanged shape; `behind-declared` is no longer returned when the declared stage is the last node.
- [ ] **Step 1: Write the failing tests**
In `server/__tests__/lanes-lib.test.js`, beside the existing `behind-declared` tests:
```js
it("a declaration on the pipeline's LAST node no longer vetoes detection", () => {
const l = lanes.createLane({ title: "t", cwd: "/tmp/last-node", pipeline: "default" });
lanes.setStage(l.id, { stage: "done" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), {
written: true,
});
assert.equal(lanes.getLane(l.id).detected_stage, "implement");
assert.equal(lanes.getLane(l.id).stage, "done"); // detection never moves the declaration
});
it("a declaration on the last node's ALIAS also stops vetoing", () => {
const l = lanes.createLane({ title: "t", cwd: "/tmp/last-alias", pipeline: "default" });
lanes.setStage(l.id, { stage: "completed" }); // alias of `done`
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement" }), { written: true });
});
it("a mid-pipeline declaration still vetoes a detection behind it", () => {
const l = lanes.createLane({ title: "t", cwd: "/tmp/mid-veto", pipeline: "default" });
lanes.setStage(l.id, { stage: "review" });
assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement" }), {
written: false,
reason: "behind-declared",
});
});
```
- [ ] **Step 2: Run them to verify the first two fail**
Run: `node --test server/__tests__/lanes-lib.test.js`
Expected: FAIL — `{written: false, reason: "behind-declared"}` for the first two; the third already passes.
- [ ] **Step 3: Implement the exception**
In `recordDetection`, replace the declared-wins check with:
```js
// Declared wins — an agent's own claim outranks an inference and never
// expires. The one exception: a declaration on the pipeline's LAST node
// means the pipeline is finished, so any later detection is new work rather
// than a stale rerun of work already claimed. Without it a lane left at
// `done` can never be detected again, which reads as "auto-detect is
// broken" for the rest of that lane's life. Still only paints the amber
// detected badge — `stage` is untouched here, as everywhere in this
// function.
const declaredIdx = phaseIdx(pipeline, lane.stage);
const isTerminalDeclaration = declaredIdx === pipeline.nodes.length - 1;
if (!isTerminalDeclaration && declaredIdx >= nodeIdx) {
return { written: false, reason: "behind-declared" };
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `node --test server/__tests__/lanes-lib.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add server/lib/lanes.js server/__tests__/lanes-lib.test.js
git commit -m "fix(lanes): let detection resume once a lane's declared stage is the pipeline's last node"
```
---
### Task 3: Codex stage detection from Codex's own rollout journals
Codex emits no hooks, so `touchLaneFromHook` — the only caller of `detect()` — never runs for a `codex` lane, and it is pinned to `"claude"` anyway (correctly: a Claude hook must never touch a codex lane sharing the path). Codex's on-disk equivalent is its rollout JSONL, one file per thread under `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl`, appended live: the first line is a `session_meta` payload carrying the thread's `cwd`, and each tool call is a `custom_tool_call`/`function_call` payload. This task adds the codex analogue of `workflow-ingest.js` — a fail-safe poll that maps those payloads onto hook-shaped events and feeds the existing detection path.
**Why the JSONL and not Codex's SQLite:** codex 0.149 keeps a `threads` table
in `$CODEX_HOME/state_5.sqlite` (with `cwd`, `rollout_path`, `history_mode` of
`legacy`/`paginated`) and items in `thread_history_1.sqlite`, but every thread
row still points at a live rollout file, and the currently-running session's
rollout was observed being appended today (`sessions/2026/08/04/rollout-…jsonl`,
a resumed thread). The JSONL is the format both history modes write, and reading
it costs no coupling to version-suffixed schema files another Codex release will
rename. If a future release stops writing it, `rolloutFiles()` is the one
function to re-point.
**Files:**
- Create: `server/lib/codex-ingest.js`
- Modify: `server/index.js` (start the poll beside `startWorkflowPoll`)
- Test: `server/__tests__/codex-ingest.test.js`
**Interfaces:**
- Consumes: `detect(pipeline, event)` from `server/lib/stage-detect.js`; `getPipeline(id)` from `server/lib/pipelines.js`; `lanesLib.listLanes()`, `lanesLib.recordDetection(id, detection)` and `lanesLib.updateLane(id, patch)` from `server/lib/lanes.js` (verified exports at `server/lib/lanes.js:623-645` — there is no `patchLane`); `broadcastLane(id)` from `server/routes/lanes.js:1317` (the same helper `server/routes/hooks.js:19` requires — never the bare `broadcast(type, data)` of `server/websocket.js:143`).
- Produces:
- `eventsFromPayload(payload) -> Array<{tool_name: string, tool_input: object|string}>` — pure, exported for tests.
- `rolloutCwd(filePath) -> string|null` — the thread's cwd from the file's first line, memoized per path.
- `ingestCodexOnce({broadcast}) -> number` — one poll pass; returns how many lanes it broadcast.
- `startCodexPoll(broadcast) -> void` — interval wiring; `DASHBOARD_CODEX_POLL_MS=0` disables.
- [ ] **Step 1: Write the failing mapping tests**
Create `server/__tests__/codex-ingest.test.js` (header first). Payload shapes are verbatim from real rollout files:
```js
const { eventsFromPayload } = require("../lib/codex-ingest");
it("maps a function_call exec_command to a Bash event", () => {
const events = eventsFromPayload({
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: "/repo" }),
});
assert.deepEqual(events, [{ tool_name: "Bash", tool_input: { command: "npm run test:server" } }]);
});
it("maps a custom_tool_call exec to a Bash event carrying the raw script", () => {
const [event] = eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input: 'const r = await tools.exec_command({cmd:"git push origin feat/x"}); text(r.output);',
});
assert.equal(event.tool_name, "Bash");
assert.match(event.tool_input.command, /git push origin feat\/x/);
});
it("maps an apply_patch script to an Edit event per touched file", () => {
const events = eventsFromPayload({
type: "custom_tool_call",
name: "exec",
input:
'const patch = "*** Begin Patch\\n*** Update File: server/lib/lanes.js\\n+x\\n*** Add File: docs/LANES.md\\n+y\\n*** End Patch";',
});
assert.deepEqual(events, [
{ tool_name: "Edit", tool_input: { file_path: "server/lib/lanes.js" } },
{ tool_name: "Write", tool_input: { file_path: "docs/LANES.md" } },
]);
});
it("maps update_plan to TaskCreate and spawn_agent to Agent", () => {
assert.deepEqual(eventsFromPayload({ type: "function_call", name: "update_plan", arguments: "{}" }), [
{ tool_name: "TaskCreate", tool_input: {} },
]);
assert.deepEqual(
eventsFromPayload({
type: "function_call",
name: "spawn_agent",
arguments: JSON.stringify({ task_name: "code_review", message: "…" }),
}),
[{ tool_name: "Agent", tool_input: { subagent_type: "code_review" } }]
);
});
it("ignores payloads that are not tool calls", () => {
assert.deepEqual(eventsFromPayload({ type: "reasoning" }), []);
assert.deepEqual(eventsFromPayload({ type: "custom_tool_call_output", name: "exec" }), []);
assert.deepEqual(eventsFromPayload(null), []);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `node --test server/__tests__/codex-ingest.test.js`
Expected: FAIL — `Cannot find module '../lib/codex-ingest'`.
- [ ] **Step 3: Implement the mapping half of `server/lib/codex-ingest.js`**
```js
/**
* @file Codex stage ingestion. Codex emits no hooks, so the hook path that
* infers a lane's stage (server/routes/hooks.js → stage-detect.detect) can
* never see a `codex` lane. Its on-disk equivalent is the rollout journal
* Codex appends live, one file per thread:
*
* $CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl
* line 1: {"type":"session_meta","payload":{"cwd":…,"session_id":…}}
* then: {"type":"response_item","payload":{"type":"custom_tool_call",…}}
*
* This module tails those files for the cwd of every `codex` lane, maps each
* tool-call payload onto the same {tool_name, tool_input} shape a Claude hook
* delivers, and feeds the EXISTING detect() + recordDetection() path — so a
* codex lane paints the same amber "detected" badge under the same rules
* (forward-only, declared-wins) and never renders `done`. Like
* workflow-ingest.js, everything here is fail-safe: a malformed or partial
* journal throws only locally and is skipped.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const PATCH_FILE_RE = /^\*\*\* (Add|Update|Delete) File: (.+)$/gm;
const SHELL_NAMES = new Set(["exec", "exec_command", "shell", "local_shell", "container.exec"]);
/** The `cmd` of a shell call, whether it arrived as JSON arguments or as an exec script. */
function commandFromPayload(payload, text) {
try {
const parsed = JSON.parse(payload.arguments);
if (parsed && typeof parsed.cmd === "string") return parsed.cmd;
if (Array.isArray(parsed?.command)) return parsed.command.join(" ");
} catch {
/* not JSON — an exec script; the detect rules match inside it verbatim */
}
return text;
}
/**
* Hook-shaped events for one rollout payload. A patch script yields one event
* per touched file (Write for a new file, Edit otherwise — the same split the
* pipeline templates' detect rules assume); a shell call yields one Bash
* event. Anything else — reasoning, messages, tool OUTPUT — yields nothing.
*/
function eventsFromPayload(payload) {
if (!payload || typeof payload !== "object") return [];
if (payload.type !== "custom_tool_call" && payload.type !== "function_call") return [];
const name = typeof payload.name === "string" ? payload.name : "";
const text = typeof payload.input === "string" ? payload.input : String(payload.arguments || "");
if (name === "update_plan") return [{ tool_name: "TaskCreate", tool_input: {} }];
if (name === "spawn_agent") {
let taskName = "";
try {
taskName = JSON.parse(payload.arguments)?.task_name || "";
} catch {
/* encrypted/malformed arguments — the tool name alone still matters */
}
return [{ tool_name: "Agent", tool_input: { subagent_type: taskName } }];
}
if (!SHELL_NAMES.has(name)) return [];
const files = [...text.matchAll(PATCH_FILE_RE)];
if (files.length) {
return files.map(([, verb, file]) => ({
tool_name: verb === "Add" ? "Write" : "Edit",
tool_input: { file_path: file.trim() },
}));
}
return [{ tool_name: "Bash", tool_input: { command: commandFromPayload(payload, text) } }];
}
```
- [ ] **Step 4: Run the mapping tests to verify they pass**
Run: `node --test server/__tests__/codex-ingest.test.js`
Expected: PASS.
- [ ] **Step 5: Write the failing end-to-end ingest test**
Append to `server/__tests__/codex-ingest.test.js`. Use a temp `CODEX_HOME` and the same in-memory DB harness `server/__tests__/lanes-lib.test.js` already uses:
```js
it("detects a stage for a codex lane from its rollout journal and mirrors status", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-"));
const dir = path.join(home, "sessions", "2026", "08", "27");
fs.mkdirSync(dir, { recursive: true });
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex", pipeline: "default" });
fs.writeFileSync(
path.join(dir, "rollout-2026-08-27T10-00-00-abc.jsonl"),
[
JSON.stringify({ type: "session_meta", payload: { cwd, session_id: "abc" } }),
JSON.stringify({
type: "response_item",
payload: {
type: "function_call",
name: "exec_command",
arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }),
},
}),
].join("\n") + "\n"
);
process.env.CODEX_HOME = home;
const broadcast = [];
ingestCodexOnce({ broadcast: (id) => broadcast.push(id) });
const after = lanes.getLane(lane.id);
assert.equal(after.detected_stage, "tests"); // `npm run test:server` → the default template's tests node
assert.equal(after.status, "running"); // fresh journal writes mirror a working lane
assert.deepEqual(broadcast, [lane.id]);
});
it("ignores a rollout whose cwd belongs to no codex lane, and claude lanes entirely", () => {
// …same fixture, but the lane is created with tool: "claude"
assert.equal(lanes.getLane(lane.id).detected_stage, null);
});
it("a second pass with no new bytes writes nothing and broadcasts nothing", () => {
// …call ingestCodexOnce twice; the second call returns 0 and leaves detected_at unchanged
});
```
- [ ] **Step 6: Run to verify it fails**
Run: `node --test server/__tests__/codex-ingest.test.js`
Expected: FAIL — `ingestCodexOnce is not a function`.
- [ ] **Step 7: Implement the ingest half**
```js
const CODEX_ACTIVE_MS = 120_000; // a journal written within 2 min means the agent is working
const TAIL_BYTES = 64 * 1024; // first sight of a file: read only its recent tail
const MAX_FILES = 40; // newest-first cap, so a years-deep sessions/ dir can't stall a pass
function codexHome() {
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
}
/** Every rollout-*.jsonl under sessions/, newest mtime first, capped. */
function rolloutFiles() {
const root = path.join(codexHome(), "sessions");
const out = [];
const walk = (dir, depth) => {
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return; // absent or unreadable — Codex may simply not be installed
}
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory() && depth < 3) walk(p, depth + 1);
else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) {
try {
out.push({ path: p, mtime: fs.statSync(p).mtimeMs, size: fs.statSync(p).size });
} catch {
/* vanished mid-scan */
}
}
}
};
walk(root, 0);
return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
}
const cwdCache = new Map(); // rollout path → thread cwd (immutable once written)
/** The thread's cwd, from the session_meta on the file's first line. */
function rolloutCwd(file) {
if (cwdCache.has(file)) return cwdCache.get(file);
let cwd = null;
try {
const head = fs.readFileSync(file, "utf8").slice(0, 64 * 1024).split("\n")[0];
cwd = JSON.parse(head)?.payload?.cwd || null;
} catch {
cwd = null;
}
cwdCache.set(file, cwd);
return cwd;
}
const offsets = new Map(); // rollout path → bytes already ingested
/**
* One poll pass. Reads only the bytes appended since the last pass (on first
* sight, only the file's recent tail — replaying a whole thread would paint
* stages from work that finished days ago). Detection is per lane, using the
* LAST event of the batch that matches, mirroring how one hook = one
* detection: recordDetection is forward-only, so ordering within a batch
* cannot drag a lane backwards.
*/
function ingestCodexOnce({ broadcast } = {}) {
const lanesLib = require("./lanes");
const { detect } = require("./stage-detect");
const { getPipeline } = require("./pipelines");
let codexLanes = [];
try {
codexLanes = lanesLib.listLanes().filter((l) => l.tool === "codex");
} catch {
return 0;
}
if (!codexLanes.length) return 0;
const touched = new Set();
for (const file of rolloutFiles()) {
const cwd = rolloutCwd(file.path);
if (!cwd) continue;
const lane = codexLanes
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
if (!lane) continue;
// First sight of a file (fresh boot, or a thread this process has never
// seen): record where it ends and detect nothing unless it is still being
// written. Replaying a finished thread would paint stages from work that
// ended days ago, and the DETECTION_TTL only limits how long that lie
// lives — it does not stop it.
const firstSight = !offsets.has(file.path);
const stale = Date.now() - file.mtime >= CODEX_ACTIVE_MS;
const from = firstSight ? Math.max(0, file.size - TAIL_BYTES) : offsets.get(file.path);
offsets.set(file.path, file.size);
if (firstSight && stale) continue;
if (file.size <= from) continue; // nothing appended (or truncated/rotated)
let chunk = "";
try {
const fd = fs.openSync(file.path, "r");
try {
const buf = Buffer.alloc(file.size - from);
fs.readSync(fd, buf, 0, buf.length, from);
chunk = buf.toString("utf8");
} finally {
fs.closeSync(fd);
}
} catch {
continue;
}
const pipeline = getPipeline(lane.pipeline);
let detection = null;
for (const line of chunk.split("\n")) {
if (!line.trim()) continue;
let payload = null;
try {
payload = JSON.parse(line).payload;
} catch {
continue; // a torn last line: the next pass re-reads from this offset onward
}
for (const event of eventsFromPayload(payload)) {
const hit = detect(pipeline, event);
if (hit) detection = hit;
}
}
let changed = false;
if (detection) {
try {
changed = lanesLib.recordDetection(lane.id, detection).written;
} catch {
/* lane deleted mid-pass, or sqlite busy — never fatal */
}
}
// `updateLane`, not a raw UPDATE: it filters through PATCHABLE, which
// already allows `status` and forbids `tool` (see server/lib/lanes.js:64).
// Same guard hook ingestion uses: only idle↔running, and only for a lane
// the dashboard did not launch (provisioning/failed are lifecycle states
// this path must never stomp on).
const active = Date.now() - file.mtime < CODEX_ACTIVE_MS;
const next = active ? "running" : "idle";
if (!lane.run_id && (lane.status === "idle" || lane.status === "running") && next !== lane.status) {
try {
lanesLib.updateLane(lane.id, { status: next });
changed = true;
} catch {
/* as above */
}
}
if (changed) touched.add(lane.id);
}
for (const id of touched) {
try {
if (broadcast) broadcast(id);
} catch {
/* a broadcast failure must not abort the pass */
}
}
return touched.size;
}
/**
* Unref'd interval so it never blocks shutdown; disable with
* DASHBOARD_CODEX_POLL_MS=0. Codex writes its journal continuously, so a
* 15s beat keeps a codex card within one node of live without re-reading
* anything it has already ingested.
*/
function startCodexPoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_CODEX_POLL_MS
? Number(process.env.DASHBOARD_CODEX_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const timer = setInterval(() => {
try {
ingestCodexOnce({ broadcast });
} catch {
/* a poll pass never throws into the event loop */
}
}, POLL_MS);
if (timer.unref) timer.unref();
}
module.exports = { eventsFromPayload, rolloutCwd, ingestCodexOnce, startCodexPoll };
```
The parsed-line loop reads `JSON.parse(line).payload`, so a real rollout
line's top-level `timestamp`/`ordinal` fields are simply ignored — but write
the test fixture with them present anyway, so it stays a fixture of the real
format rather than of this module's assumptions.
- [ ] **Step 8: Run the ingest tests to verify they pass**
Run: `node --test server/__tests__/codex-ingest.test.js`
Expected: PASS.
- [ ] **Step 9: Wire the poll into the server**
In `server/index.js`, next to the existing `startWorkflowPoll(broadcast)` call:
```js
const { broadcastLane } = require("./routes/lanes");
require("./lib/codex-ingest").startCodexPoll(broadcastLane);
```
`broadcastLane(id)` is the single-lane helper `server/routes/hooks.js:19`
already requires from the same module; the bare `broadcast` in scope there is
`broadcast(type, data)` and would be wrong. Keep the call inside whatever guard
`startWorkflowPoll` sits behind so a test/db-less boot behaves identically.
- [ ] **Step 10: Run the whole server suite**
Run: `npm run test:server`
Expected: PASS, no regressions.
- [ ] **Step 11: Commit**
```bash
git add server/lib/codex-ingest.js server/index.js server/__tests__/codex-ingest.test.js
git commit -m "feat(lanes): detect codex lane stages from codex rollout journals"
```
---
### Task 4: The run console stops pre-filling a Claude slash command for codex lanes
`pipeline_skill` is a Claude Code slash command (`/ccam:ship-feature-lane`). Pre-filled into a codex pane it is plain text Codex cannot run, which is why the codex lane in issue 11 ran for 34 minutes and never declared a stage. A codex lane opens with an empty prompt box instead.
**Files:**
- Modify: `client/src/components/run/LaneConsolePane.tsx:141-156`
- Test: `client/src/components/run/__tests__/LaneConsolePane.test.tsx`
**Interfaces:**
- Consumes: `currentLane.tool` (`"claude" | "codex"`) and `currentLane.pipeline_skill`, both already on the lane type (`client/src/lib/types.ts`).
- Produces: no new exports.
- [ ] **Step 1: Write the failing test**
In `client/src/components/run/__tests__/LaneConsolePane.test.tsx`, following the existing tool-aware tests there: render the pane for a lane with `tool: "codex"` and `pipeline_skill: "/ccam:ship-feature-lane"`, assert the prompt textarea is empty; render for the same lane with `tool: "claude"`, assert it holds `/ccam:ship-feature-lane `.
- [ ] **Step 2: Run to verify it fails**
Run: `cd client && npx vitest run src/components/run/__tests__/LaneConsolePane.test.tsx`
Expected: FAIL — the codex case is pre-filled.
- [ ] **Step 3: Implement**
```tsx
// Pre-fill the prompt with the slash command the lane's pipeline template
// names as its driver, so picking the 16-node pipeline starts the 16-step
// skill and the 8-node one the 8-step skill. Claude lanes only: the value is
// a Claude Code slash command, and Codex would take it as literal prose —
// a prompt it cannot execute is worse than an empty box.
const lastPrefill = useRef("");
const pipelineSkill = laneTool === "codex" ? "" : currentLane?.pipeline_skill || "";
```
Use the pane's existing `laneTool` value (already derived for the tool-aware lookups); do not re-derive it.
- [ ] **Step 4: Run the client suite**
Run: `npm run test:client`
Expected: PASS. A snapshot diff in `client/src/pages/__tests__/screens.snapshot.test.tsx` is possible — read the diff, confirm it is only the codex prompt box emptying, then regenerate with `cd client && npx vitest run -u`.
- [ ] **Step 5: Commit**
```bash
git add client/src/components/run/LaneConsolePane.tsx client/src/components/run/__tests__/LaneConsolePane.test.tsx
git commit -m "fix(run): don't prefill a claude slash command into a codex pane"
```
---
### Task 5: Docs, and the instruction file Codex actually reads
`CLAUDE.md` tells a Claude session to declare `ccam stage` even in plain chat. Codex never sees that file, and this repo has no `AGENTS.md` at all — so a codex lane has no instruction to declare anything. That file is also the generic answer for any repo a codex lane runs in, so it is documented as such.
**Files:**
- Create: `AGENTS.md`
- Modify: `CLAUDE.md` (the `_Last synced:_` marker; the detection invariant bullet; the lanes bullet list)
- Modify: `docs/LANES.md` (codex detection, tool-aware CLI resolution, terminal-declaration exception)
- Modify: `docs/CLI.md` (`--tool` on cwd-resolved commands, `CCAM_LANE_ID`)
- Modify: `docs/DEPLOYMENT.md` (the new `DASHBOARD_CODEX_POLL_MS` / `CODEX_HOME` env vars, beside `DASHBOARD_WORKFLOW_POLL_MS`)
- Modify: `ARCHITECTURE.md` (the ingestion paths list, beside workflow-ingest)
**What this task does NOT fix, stated plainly:** after Tasks 1-4 a codex lane's
pipeline map moves on its own (detection) and its card shows running/idle, but
its *declared* stage still depends on the codex session actually calling
`ccam stage` — CCAM has no way to inject an instruction into a CLI it merely
spawns. `AGENTS.md` is the only lever, and it is per-repository. A codex lane in
a repo without one will show detected stages and no declarations.
- [ ] **Step 1: Write `AGENTS.md`**
Short and behavioral, not a second copy of `CLAUDE.md`: point at `CLAUDE.md` for the engineering rules, then state the two things Codex specifically has to be told — declare `ccam stage <stage>` at every real transition (with `--tool codex`, or rely on `CCAM_LANE_ID` inside a dashboard-started pane), and the file-header rule from `.claude/rules/file-headers.md`.
- [ ] **Step 2: Update the docs above**
Every statement gets the exact path/command/env-var name. In `docs/LANES.md`, the stage-detection section must say plainly which lanes are detected how: `claude` lanes from hooks, `codex` lanes from `$CODEX_HOME/sessions/**/rollout-*.jsonl` on a `DASHBOARD_CODEX_POLL_MS` beat, and that a codex detection obeys the same forward-only/declared-wins rules with the new last-node exception.
- [ ] **Step 3: Bump the sync marker**
Update `_Last synced: …_` at the top of `CLAUDE.md` to this change-set's commit and date.
- [ ] **Step 4: Verify**
Run: `bash skills/file-headers/scripts/check-headers.sh` — expect exit 0.
Run: `npm run test:server && npm run test:client` — expect PASS.
- [ ] **Step 5: Commit**
```bash
git add AGENTS.md CLAUDE.md docs/LANES.md docs/CLI.md docs/DEPLOYMENT.md ARCHITECTURE.md
git commit -m "docs: codex stage detection, tool-aware lane resolution, AGENTS.md"
```