feat(lanes): prime and nudge codex lanes into declaring their own stage

A codex lane's declared stage has always depended entirely on the session
reading AGENTS.md and calling `ccam stage` on its own — no hook, no wrapper,
no enforcement. Two additions make that more reliable without touching the
declared-wins / inferred-never-done invariants:

- LaneConsolePane pre-fills a codex pane's opening prompt with a plain-text
  stage-declaration checklist built from the lane's own pipeline_nodes —
  the same up-front priming a Claude lane gets from its pre-filled
  /ccam:ship-feature-lane skill command, just as prose since Codex has no
  slash commands.
- server/lib/codex-nudge.js sweeps every 60s and types a one-line reminder
  into a lane's live tmux pane (tmux.sendText, a new literal-text primitive
  alongside sendCommand) when its fresh detected_stage has sat undeclared
  for over two minutes. It never writes stage/detected_stage itself — any
  resulting declaration is still the codex session's own real ccam stage
  call, just server-prompted instead of relying on memory alone.

server 1354/1354, client 449/449, tsc clean.
This commit is contained in:
2026-08-28 15:59:22 +07:00
parent 5c297c6483
commit 8bd74a57e6
14 changed files with 388 additions and 10 deletions
+16 -8
View File
@@ -145,19 +145,27 @@ 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("");
// 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 || "";
// Claude gets a slash command it can execute (`pipeline_skill`), which itself
// declares stage at every step. Codex has no slash commands — the same
// string would read as literal prose — so it gets the equivalent priming a
// different way: a plain-text checklist of this lane's own pipeline node ids,
// read directly off `pipeline_nodes` so it always matches the template in
// use. Without SOME opening nudge a codex lane's declared stage depends
// entirely on AGENTS.md being read on its own, which is easy to miss.
const promptPrefill =
laneTool === "codex"
? currentLane?.pipeline_nodes?.length
? `Declare progress as you go: run \`ccam stage <stage> --tool codex --evidence "..."\` immediately at each real transition through this pipeline — ${currentLane.pipeline_nodes.map((n) => n.id).join(" -> ")}. Then, your task:\n\n`
: ""
: currentLane?.pipeline_skill || "";
useEffect(() => {
const next = pipelineSkill ? `${pipelineSkill} ` : "";
const next = promptPrefill && laneTool === "claude" ? `${promptPrefill} ` : promptPrefill;
setPrompt((p) => (p === lastPrefill.current ? next : p));
lastPrefill.current = next;
// `laneId` is a dep too: switching between two lanes on the SAME template
// leaves `pipelineSkill` unchanged, and the switch effect just cleared the
// leaves `promptPrefill` unchanged, and the switch effect just cleared the
// prompt — without it the new lane would open with an empty box.
}, [pipelineSkill, laneId]);
}, [promptPrefill, laneTool, laneId]);
// The switch effect above only sees whatever `activeRuns` the page already
// had loaded at that instant. Remounting this page (navigating away and
@@ -215,13 +215,40 @@ describe("LaneConsolePane", () => {
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.
// hour with an untouched pipeline map. No pipeline_nodes here either, so
// there's nothing to build a stage checklist from — the box stays empty.
const lane = { ...LANE, tool: "codex" as const, pipeline_skill: "/ccam:ship-feature-lane" };
render(<LaneConsolePane {...baseProps()} lanes={[lane]} />);
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
await waitFor(() => expect(prompt.value).toBe(""));
});
it("pre-fills a codex pane with a stage-declaration checklist built from its own pipeline nodes", async () => {
// Codex has no slash commands, so it can't run pipeline_skill as-is — it
// gets the same priming Claude gets (an opening nudge to declare stage)
// as plain text instead, naming this lane's actual node ids.
const lane = {
...LANE,
tool: "codex" as const,
pipeline_skill: "/ccam:ship-feature-lane",
pipeline_nodes: [
{
id: "implement",
label: "Implement",
icon: "code",
gate: false,
state: "pending" as const,
},
{ id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const },
],
};
render(<LaneConsolePane {...baseProps()} lanes={[lane]} />);
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
await waitFor(() => expect(prompt.value).toContain("ccam stage <stage> --tool codex"));
expect(prompt.value).toContain("implement -> tests");
expect(prompt.value).not.toContain("/ccam:ship-feature-lane");
});
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();