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:
@@ -41,6 +41,12 @@ 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.
|
||||
|
||||
If you go a while without declaring while that inference has clearly moved
|
||||
past your last declared stage, the dashboard may type a one-line `[ccam]`
|
||||
reminder straight into this pane asking you to run `ccam stage`. That is the
|
||||
dashboard, not the user — treat it as the same instruction as this section and
|
||||
just make the real call.
|
||||
|
||||
## Where to work
|
||||
- `server/` for API/routes/data processing.
|
||||
- `client/` for React UI behavior.
|
||||
|
||||
@@ -1569,6 +1569,30 @@ 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.
|
||||
|
||||
### Codex declared-stage reliability (`server/lib/codex-nudge.js`)
|
||||
|
||||
Detection alone only paints the amber badge; a codex lane's *declared* stage
|
||||
depends on the session calling `ccam stage` itself, so two mechanisms make
|
||||
that more likely without weakening declared-wins/inferred-never-`done`:
|
||||
|
||||
- **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex
|
||||
pane's first prompt is pre-filled with a plain-text checklist of
|
||||
`ccam stage <stage> --tool codex` calls, one per node of `Lane.pipeline_nodes`
|
||||
— the same up-front nudge a Claude lane gets from its pre-filled
|
||||
`/ccam:ship-feature-lane` skill command, in plain text since Codex has no
|
||||
slash commands.
|
||||
- **Stale-detection nudge** (`sweepCodexNudges`, an unref'd 60 s interval from
|
||||
`server/index.js`, tunable via `DASHBOARD_CODEX_NUDGE_MS`, `0` disables):
|
||||
when a codex lane's fresh (non-stale) `detected_stage` has sat ahead of its
|
||||
declared `stage` for at least two minutes and hasn't already been nudged for
|
||||
that exact detected stage, it types a one-line `[ccam] ... run: ccam stage
|
||||
...` reminder into the lane's live tmux pane via `tmux.sendText` (skipped if
|
||||
the pane isn't running an interactive program — `pty-run.js`'s
|
||||
`SHELL_COMMANDS` — since Enter there would execute the reminder as a shell
|
||||
command instead of submitting it as a chat turn). It never writes `stage` or
|
||||
`detected_stage` — any resulting declaration is still one the codex session
|
||||
made itself, through the real `ccam stage` call.
|
||||
|
||||
---
|
||||
|
||||
## Agent Extension Layer
|
||||
|
||||
@@ -111,6 +111,11 @@ 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`.
|
||||
Two things help it get there: the run console pre-fills a codex pane's opening
|
||||
prompt with a stage-declaration checklist built from its own pipeline nodes,
|
||||
and a 60s sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line
|
||||
reminder into a lane's pane if its detected stage sits fresh and undeclared for
|
||||
more than two minutes.
|
||||
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+17
-1
@@ -77,7 +77,23 @@ 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.
|
||||
stages only when something tells it to. Two things do:
|
||||
|
||||
- **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's
|
||||
first prompt with a plain-text list of `ccam stage <stage> --tool codex`
|
||||
calls to make, one per node of the lane's own pipeline template (built from
|
||||
`Lane.pipeline_nodes`, so it always matches whatever template is in use) —
|
||||
the same up-front priming a Claude lane gets from its pre-filled
|
||||
`/ccam:ship-feature-lane` skill command, just as plain text since Codex has
|
||||
no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`.
|
||||
- **A stale-detection nudge.** `server/lib/codex-nudge.js` sweeps every 60s
|
||||
(`DASHBOARD_CODEX_NUDGE_MS` to change, `0` to disable) for a codex lane whose
|
||||
fresh, non-stale `detected_stage` has sat ahead of its declared `stage` for
|
||||
at least two minutes, and types a one-line `[ccam] ... run: ccam stage ...`
|
||||
reminder straight into that lane's live tmux pane (`tmux.sendText`, skipped
|
||||
if the pane is at a bare shell prompt). It never writes `stage` or
|
||||
`detected_stage` itself — declared-wins and inferred-never-`done` are
|
||||
untouched — it only prods the session into making the real declaration.
|
||||
|
||||
## Destructive lane actions
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -633,6 +633,7 @@ function purgeLaneSessions(laneId) {
|
||||
module.exports = {
|
||||
DEAD_SEC,
|
||||
VALID_TOOLS,
|
||||
detectionIsStale,
|
||||
createLane,
|
||||
listLanes,
|
||||
getLane,
|
||||
|
||||
@@ -288,4 +288,5 @@ module.exports = {
|
||||
listRuns,
|
||||
laneIdFromRunId,
|
||||
runIdForLane,
|
||||
SHELL_COMMANDS,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user