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
+3 -1
View File
@@ -240,7 +240,7 @@ A lane is a durable unit of parallel agent work — one working directory, many
| `ccam lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
| `ccam ship-full "<requirement>" [<id>] [--print]` | **The 16-stage lane pipeline.** Puts the lane on the `ship-feature` template and starts a `claude` session running its skill (`/ccam:ship-feature-lane`): implement → gates → e2e → review → local QC → senior GO/NO-GO → push branch + open PR, then watches the PR until a human merges it. Use when the work should end in a reviewable PR |
| `ccam ship-quick "<requirement>" [<id>] [--print]` | **The 8-stage in-repo pipeline.** Puts the lane on the `default` template and runs its skill (`/ccam:ship-feature`): plan → implement → tests → review → gate → ship. No PR, no browser QC. Use for work that stays in the working copy |
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it |
| `ccam stage <stage> [--tool claude\|codex] [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `<stage>` may be a node id or one of its template's aliases; a name matching neither is still recorded but warns on stderr (exit stays `0`) that the pipeline map won't show it. `--tool` picks between a `claude` and a `codex` lane sharing one directory |
| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
| `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
@@ -264,6 +264,8 @@ A lane is a durable unit of parallel agent work — one working directory, many
Omit `<id>` and the command addresses the lane owning the current directory, so a session running inside a lane never needs to know its own id. Only a leading all-digits argument is read as an id — `ccam lanes logs web --tail 4096` addresses the lane by directory, not lane 4096.
Resolution order, shared by every cwd-resolving command: `--lane`/leading id → `CCAM_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool claude|codex`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the lane owning the directory. One directory can back a `claude` lane and a `codex` lane at once, so when two candidates remain the command fails and names both instead of picking one — see [Which lane a CLI command addresses](LANES.md#which-lane-a-cli-command-addresses).
Because a lane's services are fully detached, restarting the dashboard (or `ccam update`) never stops a running stack.
### Administration
+5
View File
@@ -252,6 +252,11 @@ DASHBOARD_ALLOWED_HOSTS= # extra Host names (comma-sep) for a LAN
# Database
DASHBOARD_DB_PATH=/var/lib/agent-dashboard/dashboard.db
# Ingestion polls (0 disables either one)
DASHBOARD_WORKFLOW_POLL_MS=12000 # Workflow-tool run journals
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane stage detection
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
# Logging
LOG_LEVEL=info # debug | info | warn | error
```
+96 -3
View File
@@ -50,7 +50,7 @@ Both modes' path field has a **Browse** button next to it, opening a small folde
A lane is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add lane" modal and immutable once the lane exists. A lane's tool determines which CLI is spawned when a run starts.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude lanes render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex lanes render stage-detection badges too, fed from Codex's own rollout journals rather than hooks (see "Stage detection"), but no Sessions/Analytics/cost — and the console never pre-fills the pipeline's Claude slash command into a codex pane.
### How Codex arguments map
@@ -69,10 +69,16 @@ Codex does not emit Claude Code hooks (sessions, agent events, tool-execution tr
- **Sessions** and **Analytics** tabs — no session history or aggregated statistics
- **Cost** surface — no per-run or per-model pricing data
- **Stage detection** — automatic stage inference from tool events (`stage-detect`) never fires
**These are not bugs; they reflect Codex's architecture.** The stage you declare via `ccam stage` (or that the driving agent calls itself) still works; liveness is tracked by the lane's tmux pane (if the stack is up) and `dashboard_runs` table (recording every invocation). Cost tracking for Codex runs would require parsing Codex's own logs, a separate design not yet implemented.
**Stage detection is not in that list.** It does work for a codex lane, from
Codex's own on-disk rollout journals rather than from hooks — see "Stage
detection" below. What a codex lane cannot get for free is a *declared* stage:
the run console never pre-fills a Claude slash command into a codex pane (Codex
would read `/ccam:ship-feature-lane` as prose), so a codex session declares
stages only when something in the repository — an `AGENTS.md` — tells it to.
## Destructive lane actions
Reset a managed worktree, remove one, or purge the lane's eligible session history with the CLI:
@@ -559,7 +565,7 @@ This command:
### Full command signature
```bash
ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
ccam stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]
```
**Parameters:**
@@ -592,6 +598,8 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text
- `--cwd <path>` (optional): working directory to match against a lane's cwd. If omitted, uses the current working directory. Useful when calling from outside the repo.
- `--tool claude|codex` (optional): which tool's lane to address when one directory backs both (see "Which lane a CLI command addresses" below).
- `--status <s>` (optional): the lane's overall status (`running`, `idle`, `provisioning`). Allows bundling a status update with the stage report.
- `--evidence <text>` (optional): a short description of what was completed at this stage. Moves the node from `passed-no-evidence` to `done`.
@@ -600,6 +608,38 @@ ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text
- `--result pass|fail` (optional): explicitly mark the stage as passed or failed. Ignored if `result` was already set; set once, never overwritten by a re-report.
### Which lane a CLI command addresses
Every cwd-resolving `ccam` command (`stage`, `lanes pipeline`, `feature
activate`, `lanes up/down/logs`, …) shares one resolver, `resolveLaneArg` in
`bin/ccam.js`. It answers in this order:
1. `--lane <id>`, or a leading numeric positional.
2. `CCAM_LANE_ID` in the environment — every tmux pane the dashboard starts
carries it (`tmux new-session -e`, or an assignment prefix when the argv is
typed into a pane already at a shell prompt). A session inside a
dashboard-started pane therefore never has to guess.
3. `--tool claude|codex`, then the harness's own marker: Claude Code exports
`CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT`, so a Claude session identifies itself
for free. **Codex has no equivalent marker** (it only reads `CODEX_HOME`, and
sets `CODEX_SANDBOX*` under a sandbox policy), so a codex session outside a
dashboard pane must pass `--tool codex`.
4. The lane owning the directory — longest path-boundary match, the same rule
the server uses.
The tool matters because **one directory can back a `claude` lane AND a `codex`
lane** (the composite `idx_lanes_cwd_tool`). With two candidates at the same
directory and no signal from steps 1-3, the command **fails** and names both
lanes rather than picking one:
```
2 lanes own /home/me/repo — pass --tool <claude|codex> or --lane <id>: #23 (claude), #33 (codex)
```
That is deliberate. Silently taking the first would write a codex session's
stage onto the claude lane's card — a lie about which agent did the work, and
exactly the failure this resolver exists to prevent.
### Example: a skill reporting progress
A `build` skill might call:
@@ -730,6 +770,52 @@ also **infers** a stage from the tool events every hook already delivers
un-instrumented sessions that never call `ccam stage` — it fills in a lane's
pipeline map without requiring every skill to be rewritten.
Two event sources feed the identical matcher, one per tool:
| Lane `tool` | Source | Cadence |
| --- | --- | --- |
| `claude` | hooks — `touchLaneFromHook` in `server/routes/hooks.js` | every hook, live |
| `codex` | Codex's own rollout journals on disk — `server/lib/codex-ingest.js` | poll, `DASHBOARD_CODEX_POLL_MS` (default 15000; `0` disables) |
Codex has no hook mechanism at all, so `touchLaneFromHook` is pinned to
`"claude"` — a hook must never touch a `codex` lane sharing the same directory.
`codex-ingest.js` is the codex-side equivalent: it tails
`$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl` (`$CODEX_HOME` defaults
to `~/.codex`), reads the thread's `cwd` from the `session_meta` on each file's
first line, and maps Codex's tool calls onto the same `{tool_name, tool_input}`
shape a Claude hook delivers:
| Codex tool call | Mapped to |
| --- | --- |
| `exec` / `exec_command` / `shell` / `local_shell` | `Bash` with the extracted `cmd` |
| any of the above carrying an `apply_patch` heredoc | one `Write` (for `*** Add File:`) or `Edit` (`Update`/`Delete`) per touched file |
| `update_plan` | `TaskCreate` |
| `spawn_agent` | `Agent` with the task name as `subagent_type` |
| anything else (messages, reasoning, web search, tool OUTPUT) | ignored |
Everything past that point is shared: the same templates, the same
forward-only/declared-wins rules, the same amber-never-green boundary. Two
codex-only details:
- Only bytes appended since the last pass are read. On the first sight of a
journal only its recent tail is read, and nothing at all when that file is
already idle — replaying a thread that finished days ago would paint stages
for work nobody is doing.
- The same pass mirrors the lane's `status` between `idle` and `running` from
the journal's mtime, under the identical guard hook ingestion uses (only a
lane with no `run_id`, and only from `idle`/`running` — never from
`provisioning` or `failed`). It writes nothing else: not `stage`, not
`notes`, not `slot`/`ports`.
**A codex lane's *declared* stage is a separate matter.** CCAM cannot inject an
instruction into a CLI it merely spawns, so a codex session declares a stage
only if something tells it to — an `AGENTS.md` in the repository (this repo has
one) is the only lever. A codex lane in a repo without one shows detected stages
and no declarations. Note that from inside a codex session `ccam stage` needs to
know which lane it is: a pane the dashboard started carries `CCAM_LANE_ID`, and
anywhere else `--tool codex` disambiguates (see "Which lane a CLI command
addresses" below).
### The evidence boundary
**An inferred stage is never evidence and never renders as done.** Green
@@ -963,6 +1049,13 @@ Detection never rewinds a lane and never fights a declaration:
- **Declared beats detected.** A detection is written only when the lane's
*declared* stage index is strictly less than the detection's. A lane
already declared at `review` ignores an `implement` detection outright.
**One exception: a declaration on the pipeline's LAST node** (`done`, in both
built-in templates) stops vetoing. The pipeline is finished there, so a later
detection is new work rather than a stale rerun of what the claim covers —
without the exception a lane left at `done` rejects every detection for the
rest of its life, which reads as "auto-detect is broken". The exception
changes nothing else: the detection still only paints the amber badge, and
`lanes.stage` still says `done` until an agent declares otherwise.
- **Write-on-change only.** The database write (and the `lane_update`
WebSocket broadcast) happens only when `recordDetection` reports an actual
change. A real install saw 29,470 `Bash` events alone from one project —
@@ -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"
```