Files
AgentHub/server/lib/codex-nudge.js
T
nntrivi2001 8bd74a57e6 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.
2026-08-28 15:59:22 +07:00

103 lines
3.8 KiB
JavaScript

/**
* @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,
};