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
+17
View File
@@ -580,6 +580,23 @@ 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.
Getting a codex lane's *declared* stage to actually happen is a separate
problem — only the session itself can call `ccam stage`, and unlike a Claude
lane it has no slash command to pre-fill. `LaneConsolePane.tsx` pre-fills a
codex pane's opening prompt with a plain-text checklist instead, one
`ccam stage <stage> --tool codex` line per node of the lane's own
`pipeline_nodes`. As a safety net beyond that opening nudge,
`server/lib/codex-nudge.js`'s `sweepCodexNudges()` runs on an unref'd 60s
interval (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables): when a codex lane's fresh
`detected_stage` has sat ahead of its declared `stage` for at least two
minutes, it types a one-line `[ccam] ... run: ccam stage ...` reminder into
the lane's live tmux pane via `tmux.sendText` — skipped if the pane sits at a
bare shell prompt (`pty-run.js`'s `SHELL_COMMANDS`), since pressing Enter there
would run the reminder as a shell command instead of submitting it to Codex.
It never writes `stage` or `detected_stage`, so declared-wins and
inferred-never-`done` are untouched; any resulting declaration is still the
codex session's own real `ccam stage` call.
### Remote Data Sources
Live remote/multi-machine data collection over SSH. The dashboard pulls Claude Code history from other machines: `server/lib/remote-sync.js` uses recursive **`scp` over SSH** (built into OpenSSH — no `rsync` or extra packages on the remote) to mirror each remote's `~/.claude/projects` into a sandboxed per-source staging dir under the data dir, feeds it through the **same** importer used for local history (`scripts/import-history.js` `importFromDirectory`), and tags every imported session with the source id (`sessions.source`). Authentication defers entirely to the host SSH stack (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are stored**; every command runs via `execFile`/`spawn` argument arrays (never a shell string) and `StrictHostKeyChecking` is left at its SSH default.
+128
View File
@@ -0,0 +1,128 @@
/**
* @file Unit tests for the codex stage-declaration safety net: nudges a live
* codex pane to run `ccam stage` when detection has clearly moved past the
* declared stage and stayed there, without ever writing stage itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const SUITE_DB_PATH = path.join(
os.tmpdir(),
`dashboard-codex-nudge-${Date.now()}-${process.pid}.db`
);
process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH;
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const lanes = require("../lib/lanes");
const tmux = require("../lib/tmux");
const nudge = require("../lib/codex-nudge");
/** Backdate a lane's detected_at so it looks `minutes` old to the sweep. */
function backdateDetection(laneId, minutes) {
const iso = new Date(Date.now() - minutes * 60_000).toISOString();
db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(iso, laneId);
}
describe("codex-nudge sweep", () => {
let calls;
beforeEach(() => {
calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "codex"; // pane running the codex CLI
return "";
});
nudge.__resetNudgeState();
});
afterEach(() => {
tmux.__reset();
});
function makeCodexLane({ cwd }) {
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" });
return lane;
}
it("nudges once a fresh detection has sat ahead of the declared stage past the delay", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` });
backdateDetection(lane.id, 3); // older than the 2 min delay, well inside the 5 min TTL
assert.equal(nudge.sweepCodexNudges(), 1);
const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l");
assert.equal(sendKeys.length, 1);
assert.match(sendKeys[0][4], /ccam stage tests --tool codex/);
// A second pass for the same still-undeclared detection must not repeat it.
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges before the delay has elapsed", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` });
// detected_at is "now" — recordDetection just set it.
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a stale detection (past the detection TTL)", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c` });
backdateDetection(lane.id, 10); // past the default 5 min TTL
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a claude lane", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" });
lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` });
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a lane with no live run", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" });
lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" });
backdateDetection(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never types into a pane sitting at a bare shell prompt", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` });
backdateDetection(lane.id, 3);
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "bash";
return "";
});
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("nudges again once the target detected stage changes, after a prior nudge", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
backdateDetection(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1);
// Detection advances to a later node; forward-only recordDetection accepts it.
lanes.recordDetection(lane.id, { nodeId: "review", signal: "gh pr create" });
backdateDetection(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1);
lanes.deleteLane(lane.id);
});
});
+17
View File
@@ -50,6 +50,23 @@ describe("tmux wrapper", () => {
assert.deepEqual(calls[1], ["set-option", "-t", "ccam-lane-1", "mouse", "on"]);
});
it("sendText types the literal string and presses Enter, with no quoting", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
tmux.sendText("ccam-lane-1", 'Reminder: run "ccam stage implement"');
assert.deepEqual(calls[0], [
"send-keys",
"-t",
"ccam-lane-1",
"-l",
'Reminder: run "ccam stage implement"',
]);
assert.deepEqual(calls[1], ["send-keys", "-t", "ccam-lane-1", "Enter"]);
});
it("killSession never throws when the session is already gone", () => {
tmux.__setExecImpl(() => {
const e = new Error("no such session");
+9
View File
@@ -404,6 +404,15 @@ function startBackgroundServices() {
} catch (err) {
console.warn("codex poll failed to start:", err.message);
}
// Safety net: a codex lane only declares stage when the session calls
// `ccam stage` itself. When auto-detection has clearly moved on without a
// matching declaration, nudge the session's own pane to make the real call
// — never writes stage itself, so the declared-wins invariant is untouched.
try {
require("./lib/codex-nudge").startCodexNudge();
} catch (err) {
console.warn("codex nudge 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
+102
View File
@@ -0,0 +1,102 @@
/**
* @file Safety-net reminder for codex lanes whose auto-detected stage has
* clearly moved past the last stage the session actually *declared* (see
* `server/lib/stage-detect.js` / `server/lib/codex-ingest.js`). A codex
* session only declares a stage when it calls `ccam stage` on its own —
* there is no hook or wrapper that does it for it — so a lane can sit with a
* stale declared stage indefinitely even while its pipeline map keeps
* painting fresh amber "detected" badges.
*
* This module never writes `stage` or `detected_stage` itself — the
* declared-wins / inferred-never-`done` invariants in lanes.js are untouched.
* It only types a plain-text reminder into the lane's own live tmux pane
* (`tmux.sendText`), the same way a human watching the dashboard would nudge
* the agent — so any stage change that results is still a REAL declaration
* the codex session made itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const NUDGE_DELAY_MS = 120_000; // give the session 2 min to declare on its own first
// Per-lane memory of the last detected_stage already nudged for, so the 60s
// sweep doesn't retype the same reminder every pass. Cleared implicitly: once
// the session declares (setStage nulls detected_stage) or detection advances
// again, the value here no longer matches and a fresh nudge is due.
// ponytail: in-memory, resets on server restart — worst case one duplicate
// nudge after a restart, not worth a DB column for.
const nudgedFor = new Map();
/**
* One sweep pass: nudge every codex lane whose fresh detection sits ahead of
* its declared stage, has held for at least NUDGE_DELAY_MS, and hasn't
* already been nudged for that exact detected stage.
* @returns {number} How many lanes were nudged.
*/
function sweepCodexNudges() {
const lanes = require("./lanes");
const tmux = require("./tmux");
const { SHELL_COMMANDS, runIdForLane } = require("./pty-run");
let codexLanes = [];
try {
codexLanes = lanes.listLanes().filter((l) => l.tool === "codex" && l.run_id);
} catch {
return 0;
}
let nudged = 0;
for (const lane of codexLanes) {
try {
if (!lane.detected_stage || lane.detected_stage === lane.stage) {
nudgedFor.delete(lane.id);
continue;
}
if (lanes.detectionIsStale(lane.detected_at)) continue;
const detectedAt = Date.parse(lane.detected_at);
if (!Number.isFinite(detectedAt) || Date.now() - detectedAt < NUDGE_DELAY_MS) continue;
if (nudgedFor.get(lane.id) === lane.detected_stage) continue;
const runId = runIdForLane(lane.id);
if (!tmux.hasSession(runId)) continue;
// A bare shell prompt has no chat turn to submit into — pressing Enter
// there would run the reminder text as a shell command instead.
if (SHELL_COMMANDS.has(tmux.paneCommand(runId) || "")) continue;
tmux.sendText(
runId,
`[ccam] You appear to have moved on to "${lane.detected_stage}" — run: ccam stage ${lane.detected_stage} --tool codex --evidence "..."`
);
nudgedFor.set(lane.id, lane.detected_stage);
nudged += 1;
} catch {
/* one lane's failure must not abort the sweep */
}
}
return nudged;
}
/** Unref'd so it never blocks shutdown; disable with DASHBOARD_CODEX_NUDGE_MS=0. */
function startCodexNudge() {
const POLL_MS = process.env.DASHBOARD_CODEX_NUDGE_MS
? Number(process.env.DASHBOARD_CODEX_NUDGE_MS)
: 60_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const timer = setInterval(() => {
try {
sweepCodexNudges();
} catch {
/* a sweep pass never throws into the event loop */
}
}, POLL_MS);
if (timer.unref) timer.unref();
}
function __resetNudgeState() {
nudgedFor.clear();
}
module.exports = {
sweepCodexNudges,
startCodexNudge,
__resetNudgeState,
};
+1
View File
@@ -633,6 +633,7 @@ function purgeLaneSessions(laneId) {
module.exports = {
DEAD_SEC,
VALID_TOOLS,
detectionIsStale,
createLane,
listLanes,
getLane,
+1
View File
@@ -288,4 +288,5 @@ module.exports = {
listRuns,
laneIdFromRunId,
runIdForLane,
SHELL_COMMANDS,
};
+17
View File
@@ -9,6 +9,8 @@
* The one place a command line is composed is `sendCommand`, which types into
* an existing pane's shell: there the shell IS the consumer, so every argument
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
* `sendText` is the same primitive for the opposite case — a pane running an
* interactive chat program that reads stdin as a message, not a shell.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -104,6 +106,20 @@ function sendCommand(name, argv, env) {
execImpl(["send-keys", "-t", name, "Enter"]);
}
/**
* Type a literal line of plain text into an existing pane and press Enter —
* for a program that reads stdin as a message rather than a shell command
* (an interactive `claude`/`codex` chat pane), where `sendCommand`'s POSIX
* quoting and `KEY=value ` env prefix would show up as literal punctuation in
* the message instead of being interpreted. `text` must be single-line: `-l`
* sends it as one literal keystroke burst, so an embedded newline would submit
* only the first line and leave the rest sitting unsent in the input box.
*/
function sendText(name, text) {
execImpl(["send-keys", "-t", name, "-l", text]);
execImpl(["send-keys", "-t", name, "Enter"]);
}
/** Idempotent — a session that's already gone is not an error. */
function killSession(name) {
try {
@@ -142,6 +158,7 @@ module.exports = {
enableMouse,
paneCommand,
sendCommand,
sendText,
killSession,
listSessions,
isTmuxAvailable,