fix(lanes): auto-detect and report stage for codex lanes (issue 11)
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).
This commit is contained in:
+13
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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" } }]);
|
||||
});
|
||||
});
|
||||
@@ -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 <id> 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-"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
// `*** Update File: <path>` 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/<YYYY>/<MM>/<DD>/<file>
|
||||
|
||||
/** 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,
|
||||
};
|
||||
+12
-2
@@ -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 = ?"
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-4
@@ -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"]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user