From fd7f57929cacecedc35df2ce4d6ea4f23f4ccab5 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 27 Aug 2026 16:38:23 +0700 Subject: [PATCH] fix(lanes): auto-detect and report stage for codex lanes (issue 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- AGENTS.md | 30 + ARCHITECTURE.md | 59 ++ CLAUDE.md | 7 +- README.md | 5 +- README.vi.md | 7 +- bin/ccam.js | 79 +- client/src/components/run/LaneConsolePane.tsx | 6 +- .../run/__tests__/LaneConsolePane.test.tsx | 10 + docs/CLI.md | 4 +- docs/DEPLOYMENT.md | 5 + docs/LANES.md | 99 ++- ...26-08-27-issue-11-pipeline-detect-stage.md | 811 ++++++++++++++++++ server/README.md | 16 +- server/__tests__/codex-ingest.test.js | 524 +++++++++++ server/__tests__/lanes-cli.test.js | 123 +++ server/__tests__/lanes-lib.test.js | 116 +++ server/__tests__/pty-run.test.js | 45 +- server/index.js | 11 + server/lib/codex-ingest.js | 369 ++++++++ server/lib/lanes.js | 14 +- server/lib/pty-run.js | 10 +- server/lib/tmux.js | 28 +- 22 files changed, 2343 insertions(+), 35 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-27-issue-11-pipeline-detect-stage.md create mode 100644 server/__tests__/codex-ingest.test.js create mode 100644 server/lib/codex-ingest.js diff --git a/AGENTS.md b/AGENTS.md index e722ab34..913d857a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,36 @@ - Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*). - Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ `. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`. +## Declare your lane stage as you work + +This repository is watched by CCAM, its own dashboard. A **lane** is keyed by +working directory, and the pipeline map only moves forward when the agent doing +the work declares its stage: + +```bash +ccam stage --tool codex --evidence "" +``` + +- Call it at every real transition — starting to plan, starting to implement, + running tests, opening the PR — not once at the end. +- Stage names, `default` template: `intake → plan → implement → tests → review + → gate → ship → done` (the 16-node `ship-feature` template accepts these as + aliases too; `ccam stage` warns on stderr and names the valid nodes if a stage + matches none). +- **`--tool codex` matters.** One directory can back both a `claude` lane and a + `codex` lane, and without a tool the command fails rather than guess (it names + both candidates). Inside a pane the dashboard started you need nothing — that + pane carries `CCAM_LANE_ID`, which outranks everything but `--lane`. +- If `ccam stage` reports that no lane owns the directory, this checkout was + never adopted: skip the stage calls and carry on. Stage reporting is + reporting, never control flow — a failed `ccam stage` never justifies skipping + a step of the actual work. + +The dashboard also *infers* your stage from your tool calls (it reads Codex's +own rollout journals — see `docs/LANES.md#stage-detection`), but an inference +only paints a dashed amber badge. It never counts as evidence and never renders +a node green. Only your own declaration does. + ## Where to work - `server/` for API/routes/data processing. - `client/` for React UI behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d4d162eb..bc8cfbd1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1512,6 +1512,65 @@ changes. --- +## Codex Lane Ingestion (no hooks) + +A `codex` lane has no hook stream at all — Codex does not implement Claude +Code's hook protocol — so `touchLaneFromHook` (the only caller of +`stage-detect.detect()`) can never see one, and is deliberately pinned to +`"claude"` so a Claude hook cannot touch a codex lane sharing the same +directory. + +`server/lib/codex-ingest.js` is the codex-side source. Codex appends a rollout +journal per thread at +`$CODEX_HOME/sessions///
/rollout--.jsonl` +(`$CODEX_HOME` defaults to `~/.codex`); the first line is a `session_meta` +payload carrying the thread's `cwd`, and each tool call is a `custom_tool_call` +or `function_call` payload. One pass: + +1. `rolloutFiles()` walks `sessions/` (depth ≤ 4), newest mtime first, capped at + 40 files. +2. `rolloutCwd()` reads the thread's cwd from the file's first line (memoized — + it is written once, at thread creation) and matches it to a `codex` lane by + deepest path-boundary prefix. +3. Only bytes appended since the last pass are read. On the FIRST sight of a + file only its recent 64 KiB 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. `completeLines()` does the framing **on the + buffer, never on a decoded string** (a tail offset can land inside a + multi-byte UTF-8 sequence, where the decoder's U+FFFD would throw the byte + math off): a tail read's leading fragment is dropped, the stored offset + advances only past the last newline, and a trailing fragment is re-read whole + next pass. A read is capped at 4 MiB, and the two tracking maps are pruned to + the current scan once they exceed 4 × the 40-file cap. +4. `eventsFromPayload()` maps each payload onto the hook-shaped + `{tool_name, tool_input}` the matcher already understands: shell calls + (`exec`/`exec_command`/`shell`/`local_shell`) → `Bash`, an embedded + `apply_patch` heredoc → one `Write`/`Edit` per touched file, `update_plan` → + `TaskCreate`, `spawn_agent` → `Agent`. Everything else is ignored. Newlines + inside a patch heredoc arrive escaped (it rides inside a JS string literal), + so they are unescaped before the file list is scanned. +5. The last matching event of the batch goes through the same + `recordDetection()` as a hook — same forward-only, declared-wins, + amber-never-green rules — and the same pass mirrors the lane's `status` + between `idle` and `running` from the journal's mtime, under the identical + guard hook ingestion uses (no `run_id`, only from `idle`/`running`). Nothing + else is written: not `stage`, not `notes`, not `slot`/`ports`. + +`startCodexPoll(broadcastLane)` runs it on an unref'd 15 s interval from +`server/index.js`, tunable via `DASHBOARD_CODEX_POLL_MS` (`0` disables). Every +step is fail-safe: an absent `~/.codex`, a malformed journal, a lane deleted +mid-pass or a busy SQLite is swallowed locally, and a pass never throws into the +event loop. No schema change, no new route, no new WebSocket message type — the +existing `lane_update` broadcast carries it. + +Codex 0.149 also mirrors threads into version-suffixed SQLite files +(`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still points +at a live rollout file, so reading the JSONL avoids coupling to a schema the +next Codex release renames. `rolloutFiles()` is the single function to re-point +if that changes. + +--- + ## Agent Extension Layer The repository includes a triple extension strategy: diff --git a/CLAUDE.md b/CLAUDE.md index 1ead9a47..23f9b38a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: d884d19 (2026-08-26)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: b1734a4 (2026-08-27)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -8,7 +8,7 @@ _Last synced: d884d19 (2026-08-26)._ A change that makes any statement in this f - Keep MCP integration production-ready for local use (`mcp/`). ## Repo map -- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks). +- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, the same idea for `codex` lanes: it tails Codex's own rollout journals because Codex emits no hooks at all). - `client/`: React + Vite UI. - `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`. `POST /api/updates/apply` (only when the checkout is fast-forwardable) pulls, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; otherwise users run the printed manual command, surfaced in the UI and by `ccam update-check`.) - `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`. @@ -22,8 +22,9 @@ A **lane** is a durable unit of parallel agent work — one working directory, m **Key points:** - Sessions are bound to lanes by longest path-boundary prefix match on their `cwd` (set in hook data from `claude` CLI). - A lane's stage is **declared** by the driving Claude session (via `ccam stage [--evidence "..."]`), not inferred by the dashboard. +- **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/ccam.js`) answers in order: `--lane`/leading id → `CCAM_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work. - **The console never writes a lane's stage.** Declared stages come from `ccam stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion. -- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. +- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life. - **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently. - **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. - **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them. diff --git a/README.md b/README.md index 55610d78..77cf1a08 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,10 @@ Every lane also carries the CLI that drives it — `claude` (the default) or Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort choices, and resumes with `codex resume --last`. Codex emits none of this dashboard's hooks, so a Codex lane records its runs but stays absent from -Sessions, Analytics and stage detection. +Sessions and Analytics. Its **stage detection** works anyway, from Codex's own +rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every +`DASHBOARD_CODEX_POLL_MS` ms, default 15000) — what a Codex lane cannot get for +free is a *declared* stage, since only the session itself can call `ccam stage`. The declared stage comes from `ccam stage`. The inferred stage comes from tool events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can diff --git a/README.vi.md b/README.vi.md index 73414011..5583c11d 100644 --- a/README.vi.md +++ b/README.vi.md @@ -106,8 +106,11 @@ Mỗi lane còn mang theo CLI điều khiển nó — `claude` (mặc định) h chọn trong modal Add lane và cố định suốt vòng đời của lane. Lane Codex có panel chạy với các lựa chọn riêng của Codex (sandbox, approval, reasoning effort) và resume bằng `codex resume --last`. Codex không bắn hook của dashboard này, nên -lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions, Analytics và -stage detection. +lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics. +**Stage detection thì vẫn chạy**, đọc từ rollout journal của chính Codex trên đĩa +(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, +mặc định 15000) — thứ lane Codex không tự có là stage **khai báo**, vì chỉ chính +session đó mới gọi được `ccam stage`. Stage khai báo (declared) đến từ `ccam stage`. Stage suy luận (inferred) đến từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có diff --git a/bin/ccam.js b/bin/ccam.js index eb1b3387..378c1bc2 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -1774,9 +1774,28 @@ async function cmdLanes() { } /** - * Which lane a command is about: an explicit id, or the lane owning the working - * directory (longest path-boundary match, the same rule the server uses). A - * session running inside a lane never has to know its own id. + * Which CLI this process is running under. Claude Code exports CLAUDECODE and + * CLAUDE_CODE_ENTRYPOINT into every command it runs; Codex has no equivalent + * marker (it only READS CODEX_HOME, and sets CODEX_SANDBOX* under a sandbox + * policy), so sniffing a CODEX_ prefix would be a guess. A codex session says + * so with `--tool codex`, or needs nothing at all inside a pane the dashboard + * started — that pane carries CCAM_LANE_ID. + */ +function toolFromEnv() { + return process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT ? "claude" : null; +} + +/** + * Which lane a command is about, in order: an explicit id, the CCAM_LANE_ID of + * a dashboard-started pane, then the lane owning the working directory (longest + * path-boundary match, the same rule the server uses). A session running inside + * a lane never has to know its own id. + * + * The TOOL matters at the last step: one directory can back a `claude` lane AND + * a `codex` lane (the composite idx_lanes_cwd_tool), so cwd alone does not + * identify a lane. With two candidates and no signal — no `--tool`, no harness + * marker — this errors instead of picking one, because writing a stage onto the + * sibling tool's card is a silent lie about which agent did the work. * * Only the FIRST positional counts as an id, and only when it is all digits. * Scanning the whole argv for a number would swallow flag values — @@ -1795,17 +1814,55 @@ async function resolveLaneArg(args) { if (explicit) return { laneId: explicit, rest: args }; if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) }; + const tool = flagValue("tool") || toolFromEnv(); const cwd = require("path").resolve(flagValue("cwd") || process.cwd()); const { lanes } = await get("/api/lanes"); - const match = lanes + + // A pane's CCAM_LANE_ID beats cwd matching, but only after it is checked + // against the live lane list AND the directory: a pane outlives the lane it + // was started for, ids are reused after a delete, and an agent that cd'd into + // another repo is no longer working in its pane's lane. Trusting the number + // blind would write this session's stage onto whatever lane inherited the id. + const fromPane = process.env.CCAM_LANE_ID; + if (fromPane && /^\d+$/.test(fromPane)) { + const paneLane = lanes.find((l) => String(l.id) === fromPane); + if (paneLane && (cwd === paneLane.cwd || cwd.startsWith(`${paneLane.cwd}/`))) { + return { laneId: paneLane.id, rest: args }; + } + } + const owning = lanes .filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`)) - .sort((a, b) => b.cwd.length - a.cwd.length)[0]; - if (!match) { + .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; } - return { laneId: match.id, rest: args }; + // Longest path still wins first; only lanes at that same directory compete, + // and among those the tool decides. + 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} — lanes here: ${nearest + .map((l) => `#${l.id} (${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 : ${candidates + .map((l) => `#${l.id} (${l.tool || "claude"})`) + .join(", ")}` + ); + process.exitCode = 1; + return null; + } + return { laneId: candidates[0].id, rest: args }; } /** One line per declared port: name, number, listening, and any base drift. */ @@ -2469,7 +2526,7 @@ async function cmdStage(args) { const stage = args[0]; if (!stage || stage.startsWith("--")) { console.error( - "usage: ccam stage [--lane ] [--cwd ] [--status ] [--evidence ] [--note ] [--result pass|fail]" + "usage: ccam stage [--lane ] [--cwd ] [--tool claude|codex] [--status ] [--evidence ] [--note ] [--result pass|fail]" ); process.exitCode = 1; return; @@ -2697,7 +2754,11 @@ const COMMAND_GROUPS = [ "[] [--holder X] [--timeout N]", "Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)", ], - ["stage [flags]", "", "Report the current pipeline stage for a lane"], + [ + "stage [flags]", + "", + "Report the current pipeline stage for a lane (--tool claude|codex when one cwd has both)", + ], ["feature list", "[]", "List every feature this lane has activated, archived or live"], [ "feature activate", diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index 7bab72e5..497bcc30 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -145,7 +145,11 @@ export function LaneConsolePane({ // templates never eats a typed requirement. Must be declared AFTER the // lane-switch effect: on a lane change that one clears both first. const lastPrefill = useRef(""); - const pipelineSkill = currentLane?.pipeline_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 (it also means the pipeline the skill drives never runs, so + // the lane declares no stage at all). + const pipelineSkill = laneTool === "codex" ? "" : currentLane?.pipeline_skill || ""; useEffect(() => { const next = pipelineSkill ? `${pipelineSkill} ` : ""; setPrompt((p) => (p === lastPrefill.current ? next : p)); diff --git a/client/src/components/run/__tests__/LaneConsolePane.test.tsx b/client/src/components/run/__tests__/LaneConsolePane.test.tsx index 092c012e..088bd4e9 100644 --- a/client/src/components/run/__tests__/LaneConsolePane.test.tsx +++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx @@ -212,6 +212,16 @@ describe("LaneConsolePane", () => { await waitFor(() => expect(prompt.value).toBe("/ccam:ship-feature-lane add a button")); }); + it("never pre-fills a claude slash command into a codex pane", async () => { + // pipeline_skill is a Claude Code slash command. Codex would take it as + // literal prose and run nothing, which is how a codex lane sits for half an + // hour with an untouched pipeline map. + const lane = { ...LANE, tool: "codex" as const, pipeline_skill: "/ccam:ship-feature-lane" }; + render(); + const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement; + await waitFor(() => expect(prompt.value).toBe("")); + }); + it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => { render(); expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); diff --git a/docs/CLI.md b/docs/CLI.md index 42a08cdd..94b02542 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -240,7 +240,7 @@ A lane is a durable unit of parallel agent work — one working directory, many | `ccam lanes reset\|remove\|purge [--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 "" [] [--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 "" [] [--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 [--evidence ] [--note ] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `` 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 [--tool claude\|codex] [--evidence ] [--note ] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary. `` 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 []` | List every feature this lane has activated, archived or live | | `ccam feature activate [--title text] []` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first | | `ccam feature show []` | 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 `` 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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 06a3b2ba..3df7d234 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 ``` diff --git a/docs/LANES.md b/docs/LANES.md index 7b5a7b4d..973753b7 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -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 [--lane ] [--cwd ] [--status ] [--evidence ] [--note ] [--result pass|fail] +ccam stage [--lane ] [--cwd ] [--tool claude|codex] [--status ] [--evidence ] [--note ] [--result pass|fail] ``` **Parameters:** @@ -592,6 +598,8 @@ ccam stage [--lane ] [--cwd ] [--status ] [--evidence ` (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 ` (optional): the lane's overall status (`running`, `idle`, `provisioning`). Allows bundling a status update with the stage report. - `--evidence ` (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 [--lane ] [--cwd ] [--status ] [--evidence `, 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 or --lane : #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///
/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 — diff --git a/docs/superpowers/plans/2026-08-27-issue-11-pipeline-detect-stage.md b/docs/superpowers/plans/2026-08-27-issue-11-pipeline-detect-stage.md new file mode 100644 index 00000000..61fa6189 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-issue-11-pipeline-detect-stage.md @@ -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ĩ `. 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=` 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 : ${candidates.map((l) => `#${l.id} (${l.tool || "claude"})`).join(", ")}` + ); + process.exitCode = 1; + return null; + } + return { laneId: candidates[0].id, rest: args }; +} +``` + +Also add `--tool ` 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///
/rollout--.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ĩ + */ + +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 ` 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" +``` diff --git a/server/README.md b/server/README.md index 506773ea..b5b2410b 100644 --- a/server/README.md +++ b/server/README.md @@ -566,9 +566,19 @@ server-inferred `detected_stage`: `server/lib/stage-detect.js` matches each hook event's tool name/input against the pipeline template's per-node `detect` rules, and `server/routes/hooks.js`'s `touchLaneFromHook` calls it on every hook before any other lane bookkeeping. It is forward-only, yields to a -declared stage that is already ahead, and — the one rule that matters — -never counts as evidence and never renders a node as `done` (see -`docs/LANES.md#stage-detection`). +declared stage that is already ahead — unless that declaration is the +pipeline's last node, where the pipeline is finished and a later detection is +new work — and, the one rule that matters, never counts as evidence and never +renders a node as `done` (see `docs/LANES.md#stage-detection`). + +A `codex` lane has no hooks to read, so `server/lib/codex-ingest.js` feeds the +same matcher from Codex's own rollout journals under +`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every +`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables) and mapping Codex's +tool calls onto the `{tool_name, tool_input}` shape a hook delivers. The same +pass mirrors that lane's `status` between `idle` and `running` from the +journal's mtime, under the guard hook ingestion uses (no `run_id`, only from +`idle`/`running`); it writes nothing else. ### Remote Data Sources diff --git a/server/__tests__/codex-ingest.test.js b/server/__tests__/codex-ingest.test.js new file mode 100644 index 00000000..f25db6ca --- /dev/null +++ b/server/__tests__/codex-ingest.test.js @@ -0,0 +1,524 @@ +/** + * @file Unit tests for Codex stage ingestion: the payload→hook-event mapping, + * and one full pass over a fixture rollout journal (detection + status mirror + + * broadcast + the no-new-bytes no-op). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const fs = require("node:fs"); +const path = require("node:path"); + +const SUITE_DB_PATH = path.join( + os.tmpdir(), + `dashboard-codex-ingest-${Date.now()}-${process.pid}.db` +); +process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH; + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const codex = require("../lib/codex-ingest"); +const lanes = require("../lib/lanes"); + +/** One rollout line in the real on-disk shape: top-level envelope + payload. */ +function line(payload, type = "response_item") { + return JSON.stringify({ timestamp: new Date().toISOString(), ordinal: 1, type, payload }); +} + +describe("codex payload mapping", () => { + it("maps a function_call exec_command to a Bash event", () => { + const events = codex.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] = codex.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 one event per touched file", () => { + const events = codex.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/NEW.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/NEW.md" } }, + ]); + }); + + it("maps an apply_patch script whose newlines are still escaped", () => { + // Verbatim from a real rollout: the patch heredoc lives inside a JS string + // literal, so after JSON.parse the line breaks are a literal backslash-n, + // not newlines. Matching only real newlines silently sees no files at all. + const events = codex.eventsFromPayload({ + type: "custom_tool_call", + name: "exec", + input: + 'const patch = "*** Begin Patch\\n*** Add File: config/demo.yml\\n+# demo\\n*** End Patch";', + }); + assert.deepEqual(events, [ + { tool_name: "Write", tool_input: { file_path: "config/demo.yml" } }, + ]); + }); + + it("maps update_plan to TaskCreate and spawn_agent to Agent", () => { + assert.deepEqual( + codex.eventsFromPayload({ type: "function_call", name: "update_plan", arguments: "{}" }), + [{ tool_name: "TaskCreate", tool_input: {} }] + ); + assert.deepEqual( + codex.eventsFromPayload({ + type: "function_call", + name: "spawn_agent", + arguments: JSON.stringify({ task_name: "code_reviewer", message: "…" }), + }), + [{ tool_name: "Agent", tool_input: { subagent_type: "code_reviewer" } }] + ); + }); + + it("ignores anything that is not a tool call", () => { + assert.deepEqual(codex.eventsFromPayload({ type: "reasoning" }), []); + assert.deepEqual( + codex.eventsFromPayload({ type: "custom_tool_call_output", name: "exec" }), + [] + ); + assert.deepEqual(codex.eventsFromPayload({ type: "function_call", name: "web_search" }), []); + assert.deepEqual(codex.eventsFromPayload(null), []); + }); +}); + +describe("codex ingest pass", () => { + const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-")); + const DIR = path.join(HOME, "sessions", "2026", "08", "27"); + let prevHome; + + before(() => { + prevHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = HOME; + fs.mkdirSync(DIR, { recursive: true }); + }); + + after(() => { + if (prevHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevHome; + codex.__resetOffsets(); + try { + fs.rmSync(HOME, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + }); + + /** A rollout journal for `cwd` with one `npm run test:server` shell call. */ + function writeRollout(name, cwd) { + const file = path.join(DIR, name); + fs.writeFileSync( + file, + [ + line({ cwd, session_id: "abc" }, "session_meta"), + line({ + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }), + }), + ].join("\n") + "\n" + ); + return file; + } + + it("detects a stage for a codex lane and mirrors its status", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + writeRollout("rollout-2026-08-27T10-00-00-aaa.jsonl", cwd); + codex.__resetOffsets(); + + const seen = []; + assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1); + const after = lanes.getLane(lane.id); + assert.equal(after.detected_stage, "tests"); // `npm run test:server` → default template's tests node + assert.match(after.detected_signal, /test:server/); + assert.equal(after.status, "running"); // a journal written just now means work is happening + assert.deepEqual(seen, [lane.id]); + lanes.deleteLane(lane.id); + }); + + it("a second pass with no appended bytes changes nothing", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + writeRollout("rollout-2026-08-27T10-00-01-bbb.jsonl", cwd); + codex.__resetOffsets(); + + codex.ingestCodexOnce({}); + const first = lanes.getLane(lane.id).detected_at; + assert.equal(codex.ingestCodexOnce({}), 0); + assert.equal(lanes.getLane(lane.id).detected_at, first); + lanes.deleteLane(lane.id); + }); + + it("never touches a claude lane, even at the journal's own cwd", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "claude-lane-")); + const lane = lanes.createLane({ title: "cc", cwd, tool: "claude" }); + writeRollout("rollout-2026-08-27T10-00-02-ccc.jsonl", cwd); + codex.__resetOffsets(); + + assert.equal(codex.ingestCodexOnce({}), 0); + assert.equal(lanes.getLane(lane.id).detected_stage, null); + lanes.deleteLane(lane.id); + }); + + it("ignores a journal whose cwd belongs to no lane", () => { + const orphan = fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-")); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + writeRollout("rollout-2026-08-27T10-00-03-ddd.jsonl", orphan); + codex.__resetOffsets(); + + assert.equal(codex.ingestCodexOnce({}), 0); + assert.equal(lanes.getLane(lane.id).detected_stage, null); + lanes.deleteLane(lane.id); + }); + + it("keeps byte offsets exact when a tail read starts mid multi-byte character", () => { + // The first sight of a >64 KiB journal reads only its tail, and that offset + // can land inside a 3-byte UTF-8 sequence. Splitting the decoded STRING and + // measuring it back with Buffer.byteLength then counts the decoder's U+FFFD + // replacement instead of the bytes actually consumed, so the next pass + // starts 1-2 bytes off and every later record is unparseable. + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + const file = path.join(DIR, "rollout-2026-08-27T10-00-06-utf8.jsonl"); + const record = (cmd) => + line({ + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd, workdir: cwd }), + }); + + // Grow an all-multi-byte filler line until the tail boundary (size - 64 KiB) + // falls on a UTF-8 continuation byte. + const head = line({ cwd, session_id: "utf8" }, "session_meta") + "\n"; + const tailRecord = record("npm run test:server") + "\n"; + let filler = "→".repeat(30_000); + for (let pad = 0; pad < 8; pad += 1) { + const body = head + filler + "\n" + tailRecord; + const buf = Buffer.from(body, "utf8"); + const boundary = buf.length - 64 * 1024; + if (boundary > 0 && (buf[boundary] & 0xc0) === 0x80) break; + filler += "x"; + } + fs.writeFileSync(file, head + filler + "\n" + tailRecord); + const buf = Buffer.from(fs.readFileSync(file)); + assert.equal(buf[buf.length - 64 * 1024] & 0xc0, 0x80, "fixture must straddle a UTF-8 char"); + + codex.__resetOffsets(); + assert.equal(codex.ingestCodexOnce({}), 1); + assert.equal(lanes.getLane(lane.id).detected_stage, "tests"); + + // The real proof: the next append must still parse, which it only does if + // the stored offset is exactly on the previous line boundary. + fs.appendFileSync(file, record("git push origin feat/x") + "\n"); + assert.equal(codex.ingestCodexOnce({}), 1); + assert.equal(lanes.getLane(lane.id).detected_stage, "ship"); + lanes.deleteLane(lane.id); + }); + + it("ingests a line appended exactly at the previous read's boundary", () => { + // The offset after a pass lands on a line boundary, so the next chunk's + // FIRST line is a whole record — dropping it as torn loses every append + // that arrives one line at a time, which is how a journal actually grows. + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + const file = path.join(DIR, "rollout-2026-08-27T10-00-04-fff.jsonl"); + fs.writeFileSync(file, line({ cwd, session_id: "boundary" }, "session_meta") + "\n"); + codex.__resetOffsets(); + codex.ingestCodexOnce({}); // consumes the header, offset now at EOF + assert.equal(lanes.getLane(lane.id).detected_stage, null); + + fs.appendFileSync( + file, + line({ + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }), + }) + "\n" + ); + assert.equal(codex.ingestCodexOnce({}), 1); + assert.equal(lanes.getLane(lane.id).detected_stage, "tests"); + lanes.deleteLane(lane.id); + }); + + it("re-reads a torn line whole on the next pass", () => { + // A pass that catches the writer mid-line must not consume that fragment: + // the record it belongs to would be lost forever. + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + const file = path.join(DIR, "rollout-2026-08-27T10-00-05-ggg.jsonl"); + const record = line({ + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }), + }); + fs.writeFileSync( + file, + line({ cwd, session_id: "torn" }, "session_meta") + "\n" + record.slice(0, 40) + ); + codex.__resetOffsets(); + codex.ingestCodexOnce({}); + assert.equal(lanes.getLane(lane.id).detected_stage, null); + + fs.appendFileSync(file, record.slice(40) + "\n"); // the writer finishes the line + assert.equal(codex.ingestCodexOnce({}), 1); + assert.equal(lanes.getLane(lane.id).detected_stage, "tests"); + lanes.deleteLane(lane.id); + }); + + it("does not replay a journal it sees for the first time already idle", () => { + // A thread that finished days ago must not paint a stage now — the first + // sight of a stale file only records where it ends. + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + const file = writeRollout("rollout-2026-08-20T10-00-00-eee.jsonl", cwd); + const old = Date.now() - 6 * 60 * 60 * 1000; + fs.utimesSync(file, old / 1000, old / 1000); + codex.__resetOffsets(); + + assert.equal(codex.ingestCodexOnce({}), 0); + assert.equal(lanes.getLane(lane.id).detected_stage, null); + lanes.deleteLane(lane.id); + }); + + it("ignores a torn/partial JSONL line at the end of a file", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-torn-line-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + const file = path.join(DIR, "rollout-torn.jsonl"); + fs.writeFileSync( + file, + [ + line({ cwd, session_id: "torn" }, "session_meta"), + line({ + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }), + }), + // A torn last line: not valid JSON, so it will be skipped on next pass + '{"timestamp":"2026-08-27T10:00:00.000Z","type":"response_item","paylo', + ].join("\n") + ); + codex.__resetOffsets(); + + const seen = []; + assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1); + const after = lanes.getLane(lane.id); + assert.equal(after.detected_stage, "tests"); // the valid line was processed + assert.deepEqual(seen, [lane.id]); + lanes.deleteLane(lane.id); + }); + + it("handles a journal whose session_meta is unparseable gracefully", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-bad-meta-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + const file = path.join(DIR, "rollout-bad-meta.jsonl"); + // Write an invalid JSON line as the first line + fs.writeFileSync(file, "not valid json at all\n"); + codex.__resetOffsets(); + + assert.equal(codex.ingestCodexOnce({}), 0); + assert.equal(lanes.getLane(lane.id).detected_stage, null); + lanes.deleteLane(lane.id); + }); + + it("picks the deepest lane when a journal's cwd matches nested lanes", () => { + const outer = fs.mkdtempSync(path.join(os.tmpdir(), "codex-outer-")); + const inner = fs.mkdtempSync(path.join(outer, "inner")); + const outerLane = lanes.createLane({ title: "outer", cwd: outer, tool: "codex" }); + const innerLane = lanes.createLane({ title: "inner", cwd: inner, tool: "codex" }); + const file = path.join(DIR, "rollout-nested.jsonl"); + fs.writeFileSync( + file, + [ + line({ cwd: inner, session_id: "nested" }, "session_meta"), + line({ + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd: "npm run test:server", workdir: inner }), + }), + ].join("\n") + "\n" + ); + codex.__resetOffsets(); + + const seen = []; + assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1); + // Inner lane should be the one detected, not outer + assert.equal(lanes.getLane(innerLane.id).detected_stage, "tests"); + assert.equal(lanes.getLane(outerLane.id).detected_stage, null); + assert.deepEqual(seen, [innerLane.id]); + lanes.deleteLane(innerLane.id); + lanes.deleteLane(outerLane.id); + }); + + it("does not touch status of a lane with an active run_id", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-run-id-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + lanes.updateLane(lane.id, { run_id: "active-run" }); + writeRollout("rollout-2026-08-27T10-00-10-rrr.jsonl", cwd); + codex.__resetOffsets(); + + codex.ingestCodexOnce({}); + const after = lanes.getLane(lane.id); + // Status should not change because run_id is present + assert.equal(after.status, "idle"); + lanes.deleteLane(lane.id); + }); + + it("does not change status from provisioning or failed", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-status-protected-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + lanes.updateLane(lane.id, { status: "provisioning" }); + writeRollout("rollout-2026-08-27T10-00-11-ppp.jsonl", cwd); + codex.__resetOffsets(); + + codex.ingestCodexOnce({}); + assert.equal(lanes.getLane(lane.id).status, "provisioning"); + + lanes.updateLane(lane.id, { status: "failed" }); + codex.ingestCodexOnce({}); + assert.equal(lanes.getLane(lane.id).status, "failed"); + lanes.deleteLane(lane.id); + }); + + it("respects DASHBOARD_CODEX_POLL_MS=0 to disable polling", () => { + const oldMs = process.env.DASHBOARD_CODEX_POLL_MS; + try { + process.env.DASHBOARD_CODEX_POLL_MS = "0"; + // startCodexPoll should return early and not set up an interval + const timer = codex.startCodexPoll(() => {}); + assert.equal(timer, undefined); + } finally { + if (oldMs === undefined) delete process.env.DASHBOARD_CODEX_POLL_MS; + else process.env.DASHBOARD_CODEX_POLL_MS = oldMs; + } + }); + + it("handles a shell call with command as an array in arguments", () => { + const events = codex.eventsFromPayload({ + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ command: ["npm", "run", "test:server"], workdir: "/repo" }), + }); + // Should join array elements + assert.deepEqual(events, [ + { tool_name: "Bash", tool_input: { command: "npm run test:server" } }, + ]); + }); + + it("ignores a payload with a non-string name", () => { + // A payload with name as null or number should not match any tool + const events1 = codex.eventsFromPayload({ + type: "function_call", + name: null, + arguments: "{}", + }); + assert.deepEqual(events1, []); + + const events2 = codex.eventsFromPayload({ + type: "custom_tool_call", + name: 123, + input: "some script", + }); + assert.deepEqual(events2, []); + }); + + it("uses the LAST detection in a batch to move the lane forward", () => { + // Within one ingest pass, if multiple tool calls map to different stages, + // recordDetection is called in order. Only the LAST one that actually writes + // should move the lane forward (forward-only). Simulate two tool calls: + // first impl call (implement), then tests call (tests). + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-batch-last-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + const file = path.join(DIR, "rollout-batch-last.jsonl"); + fs.writeFileSync( + file, + [ + line({ cwd, session_id: "batch" }, "session_meta"), + // Edit event (implement node) + line({ + type: "custom_tool_call", + name: "exec", + input: 'await tools.edit("src/main.js", "add x");', + }), + // Bash event (tests node) + line({ + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ cmd: "npm run test:server", workdir: cwd }), + }), + ].join("\n") + "\n" + ); + codex.__resetOffsets(); + + const seen = []; + assert.equal(codex.ingestCodexOnce({ broadcast: (id) => seen.push(id) }), 1); + const after = lanes.getLane(lane.id); + // Should detect the tests node (later in batch), not implement + assert.equal(after.detected_stage, "tests"); + lanes.deleteLane(lane.id); + }); + + it("limits file scan to MAX_FILES newest files", () => { + // Create more than MAX_FILES journal files and verify only newest are scanned + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-max-files-")); + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + + // Create 50 old files (more than MAX_FILES=40) + const oldTime = Date.now() - 2 * 60 * 60 * 1000; + for (let i = 0; i < 50; i++) { + const file = path.join(DIR, `rollout-old-${String(i).padStart(2, "0")}.jsonl`); + fs.writeFileSync(file, line({ cwd, session_id: `old-${i}` }, "session_meta") + "\n"); + fs.utimesSync(file, oldTime / 1000, oldTime / 1000); + } + codex.__resetOffsets(); + + // The scan should not stall and should return (no detection expected since all files are stale) + const result = codex.ingestCodexOnce({}); + // All files are stale and not active, so no broadcast + assert.equal(result, 0); + lanes.deleteLane(lane.id); + }); + + it("detects correctly with Delete File in patch heredoc", () => { + const events = codex.eventsFromPayload({ + type: "custom_tool_call", + name: "exec", + input: 'const patch = "*** Begin Patch\\n*** Delete File: old/removed.js\\n*** End Patch";', + }); + // Delete creates an Edit event (not Write, not ignored) + assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "old/removed.js" } }]); + }); + + it("handles file paths with whitespace in patch patches", () => { + const events = codex.eventsFromPayload({ + type: "custom_tool_call", + name: "exec", + input: + 'const patch = "*** Begin Patch\\n*** Update File: src/my file.js \\n*** End Patch";', + }); + // File path should be trimmed + assert.deepEqual(events, [{ tool_name: "Edit", tool_input: { file_path: "src/my file.js" } }]); + }); +}); diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index 5d2d5905..f240a3b8 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -229,6 +229,129 @@ describe("ccam stage", () => { }); }); +// A cwd can back one `claude` lane AND one `codex` lane (idx_lanes_cwd_tool), +// so cwd alone no longer identifies a lane. Picking the first of the tie means +// a codex session's `ccam stage` silently drives the claude lane's card. +describe("ccam stage in a directory shared by two lanes", () => { + const DUAL_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-dual-lane-")); + let claudeLane; + let codexLane; + // The suite itself runs under Claude Code, whose CLAUDECODE marker would + // leak into every CLI child and answer the question under test. + const NO_HARNESS = { CLAUDECODE: "", CLAUDE_CODE_ENTRYPOINT: "" }; + + before(async () => { + claudeLane = (await post("/api/lanes", { cwd: DUAL_DIR, title: "dual claude" })).body.lane; + codexLane = (await post("/api/lanes", { cwd: DUAL_DIR, title: "dual codex", tool: "codex" })) + .body.lane; + assert.notEqual(claudeLane.id, codexLane.id); + }); + + async function stageOf(id) { + return (await get(`/api/lanes/${id}`)).body.lane.stage; + } + + it("CCAM_LANE_ID wins — a dashboard-started pane always knows its own lane", async () => { + const r = await cli(["stage", "gate"], DUAL_DIR, { + ...NO_HARNESS, + CCAM_LANE_ID: String(codexLane.id), + }); + assert.equal(r.status, 0, r.stderr); + assert.equal(await stageOf(codexLane.id), "gate"); + assert.notEqual(await stageOf(claudeLane.id), "gate"); + }); + + it("--tool picks the lane when there is no env marker", async () => { + const r = await cli(["stage", "tests", "--tool", "codex"], DUAL_DIR, NO_HARNESS); + assert.equal(r.status, 0, r.stderr); + assert.equal(await stageOf(codexLane.id), "tests"); + assert.notEqual(await stageOf(claudeLane.id), "tests"); + }); + + it("CLAUDECODE in the environment resolves to the claude lane", async () => { + const r = await cli(["stage", "implement"], DUAL_DIR, { CLAUDECODE: "1" }); + assert.equal(r.status, 0, r.stderr); + assert.equal(await stageOf(claudeLane.id), "implement"); + assert.notEqual(await stageOf(codexLane.id), "implement"); + }); + + it("fails loudly, naming both candidates, when nothing identifies the tool", async () => { + const before = [await stageOf(claudeLane.id), await stageOf(codexLane.id)]; + const r = await cli(["stage", "done"], DUAL_DIR, NO_HARNESS); + assert.notEqual(r.status, 0); + const out = `${r.stdout}${r.stderr}`; + assert.match(out, /--tool/); + assert.match(out, new RegExp(`#${claudeLane.id}\\b`)); + assert.match(out, new RegExp(`#${codexLane.id}\\b`)); + assert.deepEqual([await stageOf(claudeLane.id), await stageOf(codexLane.id)], before); + }); + + it("refuses when the requested tool owns no lane here", async () => { + const solo = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-solo-lane-")); + await post("/api/lanes", { cwd: solo, title: "solo claude" }); + const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS); + assert.notEqual(r.status, 0); + assert.match(`${r.stdout}${r.stderr}`, /no codex lane/i); + }); + + it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => { + // Even with two lanes at the same cwd, --lane bypasses all resolution + const r = await cli(["stage", "review", "--lane", String(codexLane.id)], DUAL_DIR, NO_HARNESS); + assert.equal(r.status, 0, r.stderr); + assert.equal(await stageOf(codexLane.id), "review"); + assert.notEqual(await stageOf(claudeLane.id), "review"); + }); + + it("positional numeric ID acts like --lane for commands that support it (e.g. ccam lanes pipeline)", async () => { + // Commands like `ccam lanes pipeline` treat a leading numeric arg as the lane ID. + // Other commands like `ccam stage` have their own positional argument and don't support it. + const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, new RegExp(`lane #${codexLane.id}`)); + }); + + it("non-numeric CCAM_LANE_ID is ignored", async () => { + // If CCAM_LANE_ID is garbage (not a number), it should be skipped and + // fall through to --tool or env markers + const r = await cli(["stage", "ship"], DUAL_DIR, { + ...NO_HARNESS, + CCAM_LANE_ID: "not-a-number", + CLAUDECODE: "1", // This env marker should pick claude lane + }); + assert.equal(r.status, 0, r.stderr); + assert.equal(await stageOf(claudeLane.id), "ship"); + assert.notEqual(await stageOf(codexLane.id), "ship"); + }); + + it("a CCAM_LANE_ID naming no lane falls through instead of addressing it", async () => { + // A pane outlives the lane it was started for, and lane ids are reused + // after a delete. Trusting the number blind would either 404 or, worse, + // write this session's stage onto whatever lane inherited that id. + const r = await cli(["stage", "gate"], DUAL_DIR, { + ...NO_HARNESS, + CCAM_LANE_ID: "999999", + CLAUDECODE: "1", + }); + assert.equal(r.status, 0, r.stderr); + assert.equal(await stageOf(claudeLane.id), "gate"); + }); + + it("a CCAM_LANE_ID for a lane outside this directory is not used", async () => { + // The pane's lane is the truth only while the command runs inside it; an + // agent that cd'd into a different repo must resolve by that directory. + const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-elsewhere-")); + const other = (await post("/api/lanes", { cwd: elsewhere, title: "elsewhere" })).body.lane; + const r = await cli(["stage", "review"], DUAL_DIR, { + ...NO_HARNESS, + CCAM_LANE_ID: String(other.id), + CLAUDECODE: "1", + }); + assert.equal(r.status, 0, r.stderr); + assert.equal(await stageOf(claudeLane.id), "review"); + assert.notEqual(await stageOf(other.id), "review"); + }); +}); + describe("ccam lanes add", () => { it("creates a lane and it appears in lanes list", async () => { const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-add-")); diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index 2135f3a7..36aa9429 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -551,6 +551,28 @@ describe("stage detection", () => { lanes.deleteLane(l.id); }); + it("a declaration on the pipeline's LAST node stops vetoing detection", () => { + // The pipeline is over, so a later detection is new work — not a stale + // rerun of work the declaration already covers. Without this a lane left + // at `done` can never be detected again for the rest of its life. + const l = lanes.createLane({ cwd: "/tmp/wt-detect-terminal" }); + lanes.setStage(l.id, { stage: "done" }); + assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }), { + written: true, + }); + const after = lanes.getLane(l.id); + assert.equal(after.detected_stage, "implement"); + assert.equal(after.stage, "done"); // detection never moves the declaration + lanes.deleteLane(l.id); + }); + + it("a declaration on the last node's ALIAS also stops vetoing", () => { + const l = lanes.createLane({ cwd: "/tmp/wt-detect-terminal-alias" }); + lanes.setStage(l.id, { stage: "completed" }); + assert.deepEqual(lanes.recordDetection(l.id, { nodeId: "implement" }), { written: true }); + lanes.deleteLane(l.id); + }); + it("reports unknown-node for a node id the pipeline does not have", () => { const l = lanes.createLane({ cwd: "/tmp/wt-detect-unknown" }); const result = lanes.recordDetection(l.id, { nodeId: "not-a-real-node" }); @@ -973,4 +995,98 @@ describe("the forward-only hold is short enough for one work session", () => { assert.equal(lanes.getLane(l.id).detected_stage, "tests"); lanes.deleteLane(l.id); }); + + it("a terminal declaration still honors forward-only among detections", () => { + // The exception for terminal nodes is: a NEW detection can land after a + // terminal declaration. But forward-only WITHIN that detection batch still + // holds — you cannot move backwards among detections. + const l = lanes.createLane({ cwd: "/tmp/wt-terminal-forward" }); + lanes.setStage(l.id, { stage: "done" }); + lanes.recordDetection(l.id, { nodeId: "ship" }); // ahead of everything + const result = lanes.recordDetection(l.id, { nodeId: "review" }); // behind the just-recorded detection + assert.deepEqual(result, { written: false, reason: "behind-detected" }); + assert.equal(lanes.getLane(l.id).detected_stage, "ship"); + lanes.deleteLane(l.id); + }); + + it("the terminal-node exception works on the 16-node ship-feature template too", () => { + const l = lanes.createLane({ + cwd: "/tmp/wt-terminal-16node", + pipeline: "ship-feature", + }); + lanes.setStage(l.id, { stage: "done" }); + // The ship-feature template's last node is also "done", so detection should work + 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"); // stage unchanged + lanes.deleteLane(l.id); + }); + + it("detection never writes stage, even after terminal declaration", () => { + const l = lanes.createLane({ cwd: "/tmp/wt-terminal-no-stage-write" }); + lanes.setStage(l.id, { stage: "done" }); + const before = lanes.getLane(l.id); + lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }); + const after = lanes.getLane(l.id); + // stage and stage_since must not change + assert.equal(after.stage, before.stage); + assert.equal(after.stage_since, before.stage_since); + lanes.deleteLane(l.id); + }); + + it("a node with detection never renders done in the UI, even after terminal declaration", () => { + const l = lanes.createLane({ cwd: "/tmp/wt-terminal-no-done-render" }); + lanes.setStage(l.id, { stage: "done" }); + lanes.recordDetection(l.id, { nodeId: "implement", signal: "`Edit`" }); + const lane = lanes.getLane(l.id); + const payload = lanes.lanePayload(lane); + // The detected implement node must be shown as pending + detected, not done + const implementNode = payload.pipeline_nodes.find((n) => n.id === "implement"); + assert.equal(implementNode.detected, true); + assert.notEqual(implementNode.state, "done"); + lanes.deleteLane(l.id); + }); + + it("behind-detected is still returned when appropriate after terminal declaration", () => { + const l = lanes.createLane({ cwd: "/tmp/wt-terminal-behind-detected" }); + lanes.setStage(l.id, { stage: "done" }); + lanes.recordDetection(l.id, { nodeId: "tests" }); + // Next detection is behind the current detected stage + const result = lanes.recordDetection(l.id, { nodeId: "implement" }); + assert.deepEqual(result, { written: false, reason: "behind-detected" }); + lanes.deleteLane(l.id); + }); + + it("cwd-resolved single lane still resolves without flags and no env marker", () => { + // This is a basic sanity check: a lane at a cwd with no sibling lane of + // another tool should resolve with just the cwd and no --tool flag. + const cwd = "/tmp/wt-single-resolve"; + const l = lanes.createLane({ cwd, title: "solo", tool: "claude" }); + const result = lanes.resolveLaneByCwd(cwd); + assert.ok(result, "lane should resolve by cwd"); + assert.equal(result.id, l.id); + lanes.deleteLane(l.id); + }); + + it("nested cwd resolution picks the deepest lane", () => { + const outer = "/tmp/wt-nested-outer"; + const inner = "/tmp/wt-nested-outer/sub/deep"; + const outerLane = lanes.createLane({ cwd: outer, title: "outer" }); + const innerLane = lanes.createLane({ cwd: inner, title: "inner" }); + // Resolving from a path deep inside inner should pick inner + const result = lanes.resolveLaneByCwd(`${inner}/src/file.js`); + assert.equal(result.id, innerLane.id); + lanes.deleteLane(innerLane.id); + lanes.deleteLane(outerLane.id); + }); + + it("resolveLaneByCwd with explicit tool filter returns null if no lane matches", () => { + const cwd = "/tmp/wt-resolve-tool-filter"; + lanes.createLane({ cwd, title: "claude-only", tool: "claude" }); + // Explicitly request a codex lane at this cwd when only claude exists + const result = lanes.resolveLaneByCwd(cwd, "codex"); + assert.equal(result, null); + }); }); diff --git a/server/__tests__/pty-run.test.js b/server/__tests__/pty-run.test.js index 63b0975d..4653a8a0 100644 --- a/server/__tests__/pty-run.test.js +++ b/server/__tests__/pty-run.test.js @@ -82,7 +82,7 @@ describe("pty-run", () => { assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session"); const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l"); assert.ok(literal, "expected a literal send-keys with the command line"); - assert.match(literal[4], /^'claude' .*'--resume' 'abc12345'$/); + assert.match(literal[4], /^CCAM_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/); assert.ok( calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"), "expected the command to be submitted" @@ -100,6 +100,38 @@ describe("pty-run", () => { assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal); }); + it("spawnRun puts CCAM_LANE_ID in the new session's environment", () => { + let newSessionArgv = null; + tmux.__setExecImpl((args) => { + if (args[0] === "has-session") { + const e = new Error("gone"); + e.status = 1; + throw e; + } + if (args[0] === "new-session") newSessionArgv = args; + return ""; + }); + pty.spawnRun({ laneId: 33, cwd: "/tmp/repo", tool: "codex" }); + const at = newSessionArgv.indexOf("-e"); + assert.ok(at > -1, "expected a tmux -e environment flag"); + assert.equal(newSessionArgv[at + 1], "CCAM_LANE_ID=33"); + // Must stay ahead of the `--` that starts the pane's own argv. + assert.ok(at < newSessionArgv.indexOf("--")); + }); + + it("spawnRun prefixes CCAM_LANE_ID when typing into an existing shell pane", () => { + let literal = null; + tmux.__setExecImpl((args) => { + if (args[0] === "display-message") return "bash\n"; + if (args[0] === "send-keys" && args[3] === "-l") literal = args[4]; + return ""; + }); + pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" }); + // An assignment prefix on the same line: tmux's -e only applies at session + // creation, and a live shell takes its env from the command line. + assert.match(literal, /^CCAM_LANE_ID='12' 'codex'/); + }); + it("spawnRun with resumeSessionId passes --resume in argv", () => { let newSessionArgv = null; tmux.__setExecImpl((args) => { @@ -230,6 +262,11 @@ describe("pty-run", () => { return calls.find((c) => c[0] === "new-session"); } + /** The pane's own command line — everything tmux passes after the `--`. */ + function paneArgv(newSessionCall) { + return newSessionCall.slice(newSessionCall.indexOf("--") + 1); + } + it("spawnRun builds a codex argv for a codex lane", () => { const call = captureNewSession(() => pty.spawnRun({ @@ -243,7 +280,7 @@ describe("pty-run", () => { initialPrompt: "hello", }) ); - const argv = call.slice(7); + const argv = paneArgv(call); assert.deepEqual(argv, [ "codex", "-m", @@ -267,7 +304,7 @@ describe("pty-run", () => { resumeSessionId: "0199aaaa-bbbb-cccc-dddd-eeeeffff0000", }) ); - assert.deepEqual(withId.slice(7, 10), [ + assert.deepEqual(paneArgv(withId).slice(0, 3), [ "codex", "resume", "0199aaaa-bbbb-cccc-dddd-eeeeffff0000", @@ -277,7 +314,7 @@ describe("pty-run", () => { const last = captureNewSession(() => pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", tool: "codex", resumeLast: true }) ); - assert.deepEqual(last.slice(7, 10), ["codex", "resume", "--last"]); + assert.deepEqual(paneArgv(last).slice(0, 3), ["codex", "resume", "--last"]); }); it("spawnRun rejects a codex sandbox/approval outside the allowlist", () => { diff --git a/server/index.js b/server/index.js index 2410efea..3026cdac 100644 --- a/server/index.js +++ b/server/index.js @@ -393,6 +393,17 @@ function startBackgroundServices() { } catch (err) { console.warn("workflow poll failed to start:", err.message); } + // Codex lane stage detection. Codex emits no hooks at all, so its rollout + // journals on disk are the only signal a `codex` lane is working — without + // this poll such a lane's pipeline map never moves. `broadcastLane` (not the + // bare `broadcast(type, data)` above) is the single-lane push the hook route + // uses. + try { + const { broadcastLane } = require("./routes/lanes"); + require("./lib/codex-ingest").startCodexPoll(broadcastLane); + } catch (err) { + console.warn("codex poll failed to start:", err.message); + } // Continuous discovery of sessions under ~/.claude/projects. The one-time // legacy backfill above runs only once (marker-gated), so a project added // later whose sessions never flow through hooks would otherwise stay invisible diff --git a/server/lib/codex-ingest.js b/server/lib/codex-ingest.js new file mode 100644 index 00000000..df321d79 --- /dev/null +++ b/server/lib/codex-ingest.js @@ -0,0 +1,369 @@ +/** + * @file Codex stage ingestion. Codex emits no hooks, so the path that infers a + * lane's stage (server/routes/hooks.js → stage-detect.detect) can never see a + * `codex` lane — and it is deliberately pinned to `claude`, because a Claude + * hook must never touch a codex lane sharing the same directory. Codex's + * on-disk equivalent is the rollout journal it appends live, one file per + * thread: + * + * $CODEX_HOME/sessions///
/rollout--.jsonl + * line 1: {"type":"session_meta","payload":{"cwd":…,"session_id":…}} + * then: {"type":"response_item","payload":{"type":"custom_tool_call",…}} + * + * This module tails those journals 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, and a poll pass never throws. + * + * Codex 0.149 also mirrors threads into version-suffixed SQLite files + * (`state_5.sqlite`, `thread_history_1.sqlite`), but every thread row still + * points at a live rollout file, so reading the JSONL costs no coupling to a + * schema the next Codex release renames. `rolloutFiles()` is the one function + * to re-point if that ever stops being true. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); + +// `*** Update File: ` lines inside an apply_patch heredoc. +const PATCH_FILE_RE = /^\*\*\* (Add|Update|Delete) File: (.+)$/gm; +const SHELL_NAMES = new Set(["exec", "exec_command", "shell", "local_shell", "container.exec"]); + +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 live file: read only its recent tail +const MAX_FILES = 40; // newest-first cap so a years-deep sessions/ tree can't stall a pass +const MAX_READ_BYTES = 4 * 1024 * 1024; // one pass never buffers more than this per journal +const MAX_DEPTH = 4; // sessions///
/ + +/** The `cmd` of a shell call, whether it arrived as JSON arguments or an exec script. */ +function commandFromPayload(payload, text) { + try { + const parsed = JSON.parse(payload.arguments); + if (parsed && typeof parsed.cmd === "string") return parsed.cmd; + if (parsed && 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 split the + * pipeline templates' detect rules assume); a shell call yields one Bash event. + * Anything else — reasoning, messages, tool OUTPUT, a web search — yields + * nothing, so an unknown Codex tool can never be mistaken for work. + * + * @param {object|null} payload - One rollout line's `payload` object. + * @returns {Array<{tool_name: string, tool_input: object}>} + */ +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 or malformed arguments — the tool name alone still matters */ + } + return [{ tool_name: "Agent", tool_input: { subagent_type: taskName } }]; + } + if (!SHELL_NAMES.has(name)) return []; + + // A patch heredoc reaches us inside a JS string literal, so its line breaks + // are usually a literal backslash-n rather than newlines (real rollouts carry + // both forms in the same script). Unescape before scanning or the file list + // comes back empty and a whole edit looks like a plain shell command. + const patched = [...text.replace(/\\n/g, "\n").matchAll(PATCH_FILE_RE)]; + if (patched.length) { + return patched.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) } }]; +} + +function codexHome() { + return process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); +} + +/** Every rollout journal under sessions/, newest mtime first, capped at MAX_FILES. */ +function rolloutFiles() { + 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 entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (depth < MAX_DEPTH) walk(full, depth + 1); + } else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) { + try { + const stat = fs.statSync(full); + out.push({ path: full, mtime: stat.mtimeMs, size: stat.size }); + } catch { + /* vanished mid-scan */ + } + } + } + }; + walk(path.join(codexHome(), "sessions"), 0); + return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES); +} + +const cwdCache = new Map(); // rollout path → thread cwd (written once, at thread creation) + +/** The thread's working directory, 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 fd = fs.openSync(file, "r"); + try { + const buf = Buffer.alloc(64 * 1024); + const read = fs.readSync(fd, buf, 0, buf.length, 0); + const head = buf.toString("utf8", 0, read).split("\n")[0]; + cwd = JSON.parse(head).payload.cwd || null; + } finally { + fs.closeSync(fd); + } + } catch { + cwd = null; + } + if (typeof cwd !== "string" || !cwd) cwd = null; + cwdCache.set(file, cwd); + return cwd; +} + +const offsets = new Map(); // rollout path → bytes already ingested + +/** Test seam: forget every journal's ingest position (and cached cwd). */ +function __resetOffsets() { + offsets.clear(); + cwdCache.clear(); +} + +// Both maps are keyed by file path, so in a process that runs for months they +// would otherwise hold one dead entry per journal Codex ever wrote. Pruned only +// once well past the per-pass scan size, so ordinary churn keeps its positions +// and a journal that drops out of the scan behaves exactly as it does after a +// server restart: re-seen as new, and read only if it is being written to. +const MAX_TRACKED = MAX_FILES * 4; + +function pruneTracking(seen) { + for (const map of [offsets, cwdCache]) { + if (map.size <= MAX_TRACKED) continue; + for (const key of map.keys()) if (!seen.has(key)) map.delete(key); + } +} + +/** Read `[from, size)` of a file, or an empty buffer when it can't be read. */ +function readRange(file, from, size) { + try { + const fd = fs.openSync(file, "r"); + try { + const buf = Buffer.alloc(size - from); + const read = fs.readSync(fd, buf, 0, buf.length, from); + return buf.subarray(0, read); + } finally { + fs.closeSync(fd); + } + } catch { + return Buffer.alloc(0); + } +} + +/** + * The COMPLETE lines in `[from, size)`, plus the offset to resume from. + * + * Two fragments have to be handled, and only one of them is dropped: + * - a tail read (`firstSight` with `from > 0`) starts mid-line, so its leading + * fragment is discarded; + * - a read that catches the writer mid-line ends with a fragment, so the + * returned offset stops at the last newline and that fragment is re-read + * whole on the next pass. + * + * Every non-tail read therefore starts exactly on a line boundary, where the + * first line is a WHOLE record. Dropping it there — the obvious "the offset + * always lands mid-line" shortcut — loses every append that arrives one line at + * a time, which is how a journal actually grows. + * + * All of it is done on the BUFFER, never on a decoded string: a tail offset can + * land inside a multi-byte UTF-8 sequence, where the decoder emits a 3-byte + * U+FFFD for 1-2 consumed bytes. Measuring a decoded slice with + * `Buffer.byteLength` would then move the offset off the real line boundary and + * every later record would fail to parse. + */ +const NEWLINE = 0x0a; + +function completeLines(file, from, size, firstSight) { + let chunk = readRange(file, from, size); + let start = from; + if (firstSight && from > 0) { + const firstBreak = chunk.indexOf(NEWLINE); + if (firstBreak === -1) return { lines: [], next: size }; + start += firstBreak + 1; + chunk = chunk.subarray(firstBreak + 1); + } + const lastBreak = chunk.lastIndexOf(NEWLINE); + if (lastBreak === -1) return { lines: [], next: start }; + return { + lines: chunk.subarray(0, lastBreak).toString("utf8").split("\n"), + next: start + lastBreak + 1, + }; +} + +/** + * One poll pass. Reads only the bytes appended since the last pass; on the + * first sight of a file, only its recent tail, and nothing at all when the file + * is already idle — replaying a thread that finished days ago would paint + * stages from work nobody is doing. + * + * Detection uses the LAST matching event of the batch, mirroring "one hook, one + * detection"; recordDetection is forward-only, so ordering inside a batch can + * never drag a lane backwards. + * + * @param {{broadcast?: (laneId: number) => void}} [options] + * @returns {number} How many lanes actually changed (and were broadcast). + */ +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(); + const files = rolloutFiles(); + pruneTracking(new Set(files.map((f) => f.path))); + for (const file of files) { + 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; + + const firstSight = !offsets.has(file.path); + const active = Date.now() - file.mtime < CODEX_ACTIVE_MS; + let from = firstSight ? Math.max(0, file.size - TAIL_BYTES) : offsets.get(file.path); + // A stored offset past EOF means the file was truncated or a new thread took + // the same path. Restart from a bounded tail, never from 0: reading a + // multi-gigabyte journal whole would allocate it all in one buffer. + let tailOnly = firstSight; + if (from > file.size || file.size - from > MAX_READ_BYTES) { + from = Math.max(0, file.size - TAIL_BYTES); + tailOnly = true; + } + + let changed = false; + // A journal seen for the first time while already idle is history, not work: + // note where it ends and read nothing. + if (firstSight && !active) { + offsets.set(file.path, file.size); + } else if (file.size > from) { + const { lines, next } = completeLines(file.path, from, file.size, tailOnly); + offsets.set(file.path, next); + const pipeline = getPipeline(lane.pipeline); + let detection = null; + for (const raw of lines) { + if (!raw.trim()) continue; + let payload = null; + try { + payload = JSON.parse(raw).payload; + } catch { + continue; // not a record this module understands + } + for (const event of eventsFromPayload(payload)) { + const hit = detect(pipeline, event); + if (hit) detection = hit; + } + } + if (detection) { + try { + changed = lanesLib.recordDetection(lane.id, detection).written; + } catch { + /* lane deleted mid-pass, or sqlite busy — an inference is never worth throwing for */ + } + } + } + + // Status mirror, under the same guard hook ingestion uses: only idle↔running, + // and only for a lane the dashboard did not launch — provisioning and failed + // are lifecycle states this path must never stomp on. `updateLane` filters + // through PATCHABLE, which allows `status` and forbids `tool`. + 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 appends its journal continuously, so a 15s + * beat keeps a codex card within one node of live without ever re-reading what + * 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, + __resetOffsets, +}; diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 6ae18188..3aa81e8f 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -327,7 +327,8 @@ function setStage(id, { stage, status, evidence, note, result } = {}) { * not drag a lane back to `plan`); * - declared wins: the lane's DECLARED stage index is strictly less than * the detection's (a lane already declared at `review` ignores an - * `implement` detection). + * `implement` detection) — UNLESS the declaration is the pipeline's last + * node, which means the pipeline is over and a new detection is new work. * Otherwise touches nothing and reports why: `behind-detected`, * `behind-declared`, or `unknown-node`. * @@ -351,8 +352,17 @@ function recordDetection(id, { nodeId, signal } = {}) { : phaseIdx(pipeline, lane.detected_stage); if (nodeIdx <= detectedIdx) return { written: false, reason: "behind-detected" }; + // Declared wins, with ONE exception: a declaration sitting on the pipeline's + // LAST node means the pipeline is finished, so a later detection is new work + // rather than a stale rerun of what the claim already covers. Without it a + // lane left at `done` rejects every detection for the rest of its life, which + // reads to a user as "auto-detect is broken". Still paints only the amber + // detected badge — `stage` is untouched here, as everywhere in this function. const declaredIdx = phaseIdx(pipeline, lane.stage); - if (declaredIdx >= nodeIdx) return { written: false, reason: "behind-declared" }; + const declarationIsTerminal = declaredIdx === pipeline.nodes.length - 1; + if (!declarationIsTerminal && declaredIdx >= nodeIdx) { + return { written: false, reason: "behind-declared" }; + } db.prepare( "UPDATE lanes SET detected_stage = ?, detected_signal = ?, detected_at = ? WHERE id = ?" diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index ec62d31c..b14daede 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -216,8 +216,14 @@ function spawnRun(args) { }); }; + // Every pane the dashboard starts carries its lane id. A `ccam stage` call + // from inside it then resolves the lane exactly, instead of matching on cwd — + // which is ambiguous now that one directory can back both a `claude` lane and + // a `codex` lane (see bin/ccam.js's resolveLaneArg). + const env = { CCAM_LANE_ID: String(laneId) }; + if (!tmux.hasSession(id)) { - tmux.newSession({ name: id, cwd, argv }); + tmux.newSession({ name: id, cwd, argv, env }); record(); } else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) { // The session exists but its pane is sitting at a bare shell prompt — a @@ -226,7 +232,7 @@ function spawnRun(args) { // `--resume` and an initial prompt would never be typed, while the API // still answered 200. Run the argv in the pane the user already sees // instead of erroring or opening a second session. - tmux.sendCommand(id, argv); + tmux.sendCommand(id, argv, env); record(); } // Pane is running something (a live `claude`, an editor, a build): adopt diff --git a/server/lib/tmux.js b/server/lib/tmux.js index 677b470c..1a326eb5 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -34,12 +34,24 @@ function hasSession(name) { } } +/** `-e NAME=value` pairs for tmux, in a stable order. */ +function envFlags(env) { + const flags = []; + for (const [key, value] of Object.entries(env || {})) flags.push("-e", `${key}=${value}`); + return flags; +} + /** * Create a detached tmux session running `argv` as the pane's command. Throws * if tmux itself fails to start (caller decides how to surface that). + * + * `env` becomes 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 `ccam` + * call from inside the pane knows exactly which lane it belongs to instead of + * guessing from cwd — see resolveLaneArg in bin/ccam.js. */ -function newSession({ name, cwd, argv }) { - execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]); +function newSession({ name, cwd, argv, env }) { + execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), "--", ...argv]); enableMouse(name); } @@ -78,9 +90,17 @@ function shellQuote(arg) { * Type `argv` into an existing session's pane and press Enter. Only ever * called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends * the string literally so no character is read as a tmux key name. + * + * `env` rides along as a shell assignment prefix on the SAME line — tmux's own + * `-e` applies only at session creation, and a shell that is already running + * takes its environment from the command line. The name is never quoted (bash + * only recognizes an assignment when the `=` is unquoted); the value is. */ -function sendCommand(name, argv) { - execImpl(["send-keys", "-t", name, "-l", argv.map(shellQuote).join(" ")]); +function sendCommand(name, argv, env) { + const prefix = Object.entries(env || {}) + .map(([key, value]) => `${key}=${shellQuote(value)} `) + .join(""); + execImpl(["send-keys", "-t", name, "-l", prefix + argv.map(shellQuote).join(" ")]); execImpl(["send-keys", "-t", name, "Enter"]); }