feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent

- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of
  consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js)
  cuts git's fresh hunk down to it, folding the other blocks into the side
  being checked; LaneDiffView hangs one review bar per block (unified + split)
  and its arrows walk blocks.
- git exclude: entries AgentHub writes for a lane go to the COMMON git dir with
  the lane's path prefix (server/lib/git-exclude.js, used by router-apply and
  lane-mcp) — a linked worktree's own info/exclude is never read by git, and a
  subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree
  lane or creates a stray .git in a non-repo lane.
- codex lanes carry no pipeline: codex-ingest mirrors only idle/running,
  codex-nudge is removed, agents install writes nothing for codex, the
  Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and
  every server start clears leftover codex stage data and the old AGENTS.md
  stage block.
- Changes panel: a whole file opens for reading and enters the editor on a
  click on its text (caret at the clicked line), not automatically.
- macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by
  scripts/service-manager.js).
- Adds the AgentHub introduction slides and their image-embedding script.
This commit is contained in:
2026-09-25 17:30:28 +07:00
parent 3f6e903c22
commit 952e8584f0
48 changed files with 3336 additions and 1579 deletions
+6 -24
View File
@@ -584,30 +584,12 @@ 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.
Getting a codex lane's *declared* stage to actually happen is a separate
problem — only the session itself can call `agenthub stage`, and unlike a Claude
lane it has no slash command to pre-fill — and the run console pre-fills
nothing into the prompt box any more, for either tool, since anything sitting
there is sent as a real prompt on start. What is left is
`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 `[agenthub] ... run: agenthub 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 `agenthub stage` call.
A `codex` lane has no hooks to read and carries no pipeline, so nothing detects
or advances its stage. `server/lib/codex-ingest.js` only mirrors its `status`
between `idle` and `running` from the mtime of Codex's own rollout journals
under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables), under the guard hook
ingestion uses (no `run_id`, only from `idle`/`running`); it writes nothing else.
### Remote Data Sources
+74 -437
View File
@@ -1,7 +1,7 @@
/**
* @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).
* @file Unit tests for the Codex lane status mirror: one pass over fixture
* rollout journals flips a codex lane idle↔running, broadcasts it, and never
* touches a lane's stage.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -26,87 +26,11 @@ 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", () => {
describe("codex status mirror", () => {
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "codex-home-"));
const DIR = path.join(HOME, "sessions", "2026", "08", "27");
let prevHome;
let seq = 0;
before(() => {
prevHome = process.env.CODEX_HOME;
@@ -117,21 +41,18 @@ describe("codex ingest pass", () => {
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 */
}
codex.__resetCache();
fs.rmSync(HOME, { recursive: true, force: true });
});
/** A rollout journal for `cwd` with one `npm run test:server` shell call. */
function writeRollout(name, cwd) {
const file = path.join(DIR, name);
function writeRollout(cwd, { mtime } = {}) {
seq += 1;
const file = path.join(DIR, `rollout-2026-08-27T10-00-00-${seq}.jsonl`);
fs.writeFileSync(
file,
[
line({ cwd, session_id: "abc" }, "session_meta"),
line({ cwd, session_id: `s${seq}` }, "session_meta"),
line({
type: "function_call",
name: "exec_command",
@@ -139,256 +60,97 @@ describe("codex ingest pass", () => {
}),
].join("\n") + "\n"
);
if (mtime !== undefined) fs.utimesSync(file, mtime / 1000, mtime / 1000);
codex.__resetCache();
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 codexLane = (prefix = "codex-lane-") =>
lanes.createLane({
title: "cx",
cwd: fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
tool: "codex",
});
it("marks a codex lane running while its journal is fresh, and broadcasts it", () => {
const lane = codexLane();
writeRollout(lane.cwd);
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.equal(lanes.getLane(lane.id).status, "running");
assert.deepEqual(seen, [lane.id]);
lanes.deleteLane(lane.id);
});
it("advances the declared stage from the detection, without evidence", () => {
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-10-adv.jsonl", cwd);
codex.__resetOffsets();
it("never writes a stage or a detection, whatever the journal ran", () => {
const lane = codexLane();
writeRollout(lane.cwd);
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
// The ring moves — a codex session has nothing else that would move it…
assert.equal(after.stage, "tests");
// …but the node stays amber: an inference sees the tool call, not its result.
assert.equal(after.stages.tests.evidence, null);
// …and the badge survives, so the card still says WHY it moved.
assert.equal(after.detected_stage, "tests");
assert.match(after.detected_signal, /test:server/);
lanes.deleteLane(lane.id);
});
it("never drags an auto-advanced stage backwards", () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-lane-"));
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.setStage(lane.id, { stage: "review", evidence: "declared by the session" });
writeRollout("rollout-2026-08-27T10-00-11-back.jsonl", cwd); // detects `tests`, behind `review`
codex.__resetOffsets();
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
assert.equal(after.stage, "review"); // declared-wins: recordDetection refused, so no advance
assert.equal(after.stage, "idle");
assert.equal(after.detected_stage, null);
assert.equal(after.stages.review.evidence, "declared by the session");
assert.equal(after.detected_signal, null);
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();
it("a second pass with nothing new changes nothing", () => {
const lane = codexLane();
writeRollout(lane.cwd);
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("puts a running lane back to idle once its journal goes quiet", () => {
const lane = codexLane();
lanes.updateLane(lane.id, { status: "running" });
writeRollout(lane.cwd, { mtime: Date.now() - 6 * 60 * 60 * 1000 });
assert.equal(codex.ingestCodexOnce({}), 1);
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
it("treats a journal whose mtime is in the FUTURE as idle", () => {
// A clock stepped back (or a restore that preserved timestamps) leaves a
// finished thread's mtime ahead of now; raw `now - mtime` would read as live.
const lane = codexLane();
writeRollout(lane.cwd, { mtime: Date.now() + 6 * 60 * 60 * 1000 });
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).status, "idle");
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();
writeRollout(cwd);
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
assert.equal(lanes.getLane(lane.id).status, "idle");
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();
const lane = codexLane();
writeRollout(fs.mkdtempSync(path.join(os.tmpdir(), "codex-orphan-")));
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("does not replay a journal whose mtime is in the FUTURE", () => {
// A clock stepped back (or a restore that preserved timestamps) leaves a
// finished thread's mtime ahead of now. Raw `now - mtime` is then negative,
// which reads as "written milliseconds ago" and would replay it as live work.
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-27T10-00-00-fut.jsonl", cwd);
const future = Date.now() + 6 * 60 * 60 * 1000;
fs.utimesSync(file, future / 1000, future / 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]);
assert.equal(lanes.getLane(lane.id).status, "idle");
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();
const lane = codexLane();
fs.writeFileSync(path.join(DIR, "rollout-bad-meta.jsonl"), "not valid json at all\n");
codex.__resetCache();
assert.equal(codex.ingestCodexOnce({}), 0);
assert.equal(lanes.getLane(lane.id).detected_stage, null);
assert.equal(lanes.getLane(lane.id).status, "idle");
lanes.deleteLane(lane.id);
});
@@ -397,50 +159,31 @@ describe("codex ingest pass", () => {
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();
writeRollout(inner);
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.equal(lanes.getLane(innerLane.id).status, "running");
assert.equal(lanes.getLane(outerLane.id).status, "idle");
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" });
const lane = codexLane();
lanes.updateLane(lane.id, { run_id: "active-run" });
writeRollout("rollout-2026-08-27T10-00-10-rrr.jsonl", cwd);
codex.__resetOffsets();
writeRollout(lane.cwd);
codex.ingestCodexOnce({});
const after = lanes.getLane(lane.id);
// Status should not change because run_id is present
assert.equal(after.status, "idle");
assert.equal(lanes.getLane(lane.id).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" });
const lane = codexLane();
lanes.updateLane(lane.id, { status: "provisioning" });
writeRollout("rollout-2026-08-27T10-00-11-ppp.jsonl", cwd);
codex.__resetOffsets();
writeRollout(lane.cwd);
codex.ingestCodexOnce({});
assert.equal(lanes.getLane(lane.id).status, "provisioning");
@@ -455,119 +198,13 @@ describe("codex ingest pass", () => {
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);
assert.equal(
codex.startCodexPoll(() => {}),
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" } }]);
});
});
-142
View File
@@ -1,142 +0,0 @@
/**
* @file Unit tests for the codex evidence safety net: nudges a live codex pane
* to run `agenthub stage --evidence` when the lane has sat at an inferred (
* evidence-less) stage past the delay, 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 the current stage's enteredAt so it looks `minutes` old. */
function backdateStage(laneId, minutes) {
const lane = lanes.getLane(laneId);
const stages = { ...lane.stages };
stages[lane.stage] = {
...stages[lane.stage],
enteredAt: new Date(Date.now() - minutes * 60_000).toISOString(),
};
db.prepare("UPDATE lanes SET stages = ? WHERE id = ?").run(JSON.stringify(stages), 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, stage = "tests", evidence }) {
const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
lanes.setStage(lane.id, { stage, evidence });
return lane;
}
it("nudges once the lane has held an evidence-less stage past the delay", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` });
backdateStage(lane.id, 3); // older than the 2 min delay
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], /agenthub stage tests --tool codex --evidence/);
// A second pass for the same still-unevidenced stage 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` });
// enteredAt is "now" — setStage just wrote it.
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a stage the session already backed with evidence", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-c`, evidence: "12 tests green" });
backdateStage(lane.id, 10);
assert.equal(nudge.sweepCodexNudges(), 0);
lanes.deleteLane(lane.id);
});
it("never nudges a lane that has not entered a pipeline node", () => {
const cwd = os.tmpdir();
const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-h`, tool: "codex" });
lanes.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}` });
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: `agenthub-lane-${lane.id}` });
lanes.setStage(lane.id, { stage: "tests" });
backdateStage(lane.id, 3);
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.setStage(lane.id, { stage: "tests" });
backdateStage(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` });
backdateStage(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 lane moves to another evidence-less stage", () => {
const cwd = os.tmpdir();
const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1);
lanes.setStage(lane.id, { stage: "review" });
backdateStage(lane.id, 3);
assert.equal(nudge.sweepCodexNudges(), 1);
lanes.deleteLane(lane.id);
});
});
+101
View File
@@ -661,6 +661,107 @@ describe("runGitAction — one hunk at a time", () => {
});
});
describe("runGitAction — one change block inside a hunk (issue #50)", () => {
beforeEach(freshFixture);
const base = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
/** Three change blocks close enough for git to merge into ONE hunk: lines
* 4-5 replaced, a line inserted after 8, line 11 deleted. */
function threeBlocks() {
write(LANE, "near.txt", base.join("\n") + "\n");
gc(LANE, "add", "near.txt");
gc(LANE, "commit", "-qm", "near");
const next = [...base];
next.splice(3, 2, "FOUR", "FIVE");
next.splice(8, 0, "INSERTED");
next.splice(11, 1); // "line 11"
write(LANE, "near.txt", next.join("\n") + "\n");
return next;
}
const hunkOf = async () => {
const [file] = (await collectChanges(LANE, { range: "work", file: "near.txt" })).files;
return file.hunks;
};
const read = () => fs.readFileSync(path.join(LANE, "near.txt"), "utf8").split("\n");
it("sees the three blocks as one git hunk (the shape the issue reports)", async () => {
threeBlocks();
assert.equal((await hunkOf()).length, 1);
});
it("stages ONE block, leaving the other two in the working tree", async () => {
threeBlocks();
const [hunk] = await hunkOf();
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 1 });
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
assert.match(staged, /^\+INSERTED$/m);
assert.doesNotMatch(staged, /FOUR|FIVE|^-line 11$/m);
const unstaged = g(LANE, "diff", "--", "near.txt");
assert.match(unstaged, /^\+FOUR$/m);
assert.match(unstaged, /^-line 11$/m);
assert.doesNotMatch(unstaged, /^\+INSERTED$/m); // staged: only context now
});
it("stages the LAST block after earlier ones shifted the line count", async () => {
threeBlocks();
const [hunk] = await hunkOf();
await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 2 });
const staged = g(LANE, "diff", "--cached", "--", "near.txt");
assert.match(staged, /^-line 11$/m);
assert.doesNotMatch(staged, /FOUR|INSERTED/);
});
it("undoes ONE block in the working tree, keeping the blocks around it", async () => {
const edited = threeBlocks();
const [hunk] = await hunkOf();
await runGitAction(LANE, "discard-hunk", {
path: "near.txt",
hunk: hunk.header,
block: 0,
confirm: true,
});
const want = [...edited];
want.splice(3, 2, "line 4", "line 5");
assert.deepEqual(read(), [...want, ""]);
});
it("splits a hunk whose last block only drops the final newline", async () => {
// One hunk: line 2 replaced, and line 6 losing its final newline — git
// writes that second block with a "\\ No newline" marker after its "+".
write(LANE, "tail.txt", "l1\nl2\nl3\nl4\nl5\nl6\n");
gc(LANE, "add", "tail.txt");
gc(LANE, "commit", "-qm", "tail");
write(LANE, "tail.txt", "l1\nTWO\nl3\nl4\nl5\nl6");
const [file] = (await collectChanges(LANE, { range: "work", file: "tail.txt" })).files;
assert.equal(file.hunks.length, 1);
const header = file.hunks[0].header;
await runGitAction(LANE, "stage-hunk", { path: "tail.txt", hunk: header, block: 1 });
const staged = g(LANE, "diff", "--cached", "--", "tail.txt");
assert.match(staged, /No newline at end of file/);
assert.doesNotMatch(staged, /TWO/);
await runGitAction(LANE, "discard-hunk", {
path: "tail.txt",
hunk: header,
block: 0,
confirm: true,
});
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
});
it("refuses a block the hunk does not have", async () => {
threeBlocks();
const [hunk] = await hunkOf();
for (const block of [3, -1, 1.5, "0"]) {
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block }),
{ code: "EHUNKGONE" }
);
}
});
});
describe("an unborn repo and `publish`", () => {
const EMPTY = path.join(ROOT, "empty");
const URL_OK = "https://example.test/owner/r.git";
+68 -20
View File
@@ -18,7 +18,7 @@ const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const lanesLib = require("../lib/lanes");
const { installAgents } = require("../lib/lane-agents");
const { installAgents, removeStageDoc } = require("../lib/lane-agents");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
@@ -116,29 +116,77 @@ describe("installAgents", () => {
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
});
it("installs the AGENTS.md stage block for a codex lane, not .claude/agents/", async () => {
it("installs nothing for a codex lane — no .claude/agents/, no AGENTS.md", async () => {
const cwd = makeRepoDir();
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
const result = await installAgents(lane);
assert.deepEqual(result.installed, ["AGENTS.md"]);
assert.deepEqual(result.installed, []);
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
const doc = fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8");
assert.match(doc, /agenthub stage <stage> --tool codex/);
});
it("preserves the user's own AGENTS.md prose and replaces only the block", async () => {
const cwd = makeRepoDir();
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
const file = path.join(cwd, "AGENTS.md");
fs.writeFileSync(file, "# House rules\n\nNever force-push.\n");
await installAgents(lane);
await installAgents(lane); // reinstall must not duplicate the block
const doc = fs.readFileSync(file, "utf8");
assert.match(doc, /Never force-push\./);
assert.equal(doc.split("<!-- agenthub:stage:start -->").length - 1, 1);
assert.equal(doc.split("<!-- agenthub:stage:end -->").length - 1, 1);
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
});
});
describe("codex pipeline cleanup", () => {
const START = "<!-- agenthub:stage:start -->";
const END = "<!-- agenthub:stage:end -->";
const BLOCK = `${START}\n## Declare your lane stage as you work\n\nagenthub stage …\n${END}\n`;
it("removes only the AgentHub block, keeping the user's own AGENTS.md prose", () => {
const cwd = makeRepoDir();
const file = path.join(cwd, "AGENTS.md");
fs.writeFileSync(file, `# House rules\n\nNever force-push.\n\n${BLOCK}\n## More\n\nKeep me.\n`);
assert.equal(removeStageDoc(cwd), true);
const doc = fs.readFileSync(file, "utf8");
assert.ok(!doc.includes(START) && !doc.includes(END));
assert.match(doc, /Never force-push\./);
assert.match(doc, /Keep me\./);
});
it("deletes an AGENTS.md that held nothing but the block", () => {
const cwd = makeRepoDir();
fs.writeFileSync(path.join(cwd, "AGENTS.md"), BLOCK);
assert.equal(removeStageDoc(cwd), true);
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
});
it("leaves a file without the block, or no file at all, untouched", () => {
const cwd = makeRepoDir();
assert.equal(removeStageDoc(cwd), false);
fs.writeFileSync(path.join(cwd, "AGENTS.md"), "# Mine\n");
assert.equal(removeStageDoc(cwd), false);
assert.equal(fs.readFileSync(path.join(cwd, "AGENTS.md"), "utf8"), "# Mine\n");
});
it("wipes a codex lane's stage, detection, stage log and features — and no claude lane's", () => {
const codex = lanesLib.createLane({ title: "cx", cwd: makeRepoDir(), tool: "codex" });
const claude = lanesLib.createLane({ title: "cc", cwd: makeRepoDir(), tool: "claude" });
for (const lane of [codex, claude]) {
lanesLib.setStage(lane.id, { stage: "tests", evidence: "old" });
lanesLib.recordDetection(lane.id, { nodeId: "review", signal: "npm test" });
}
const { db } = require("../db");
const insertFeature = db.prepare("INSERT INTO lane_features (lane_id, slug) VALUES (?, ?)");
insertFeature.run(codex.id, "old-feature");
insertFeature.run(claude.id, "kept-feature");
assert.equal(lanesLib.clearCodexPipelines(), 1);
const cx = lanesLib.getLane(codex.id);
assert.equal(cx.stage, "idle");
assert.deepEqual(cx.stages, {});
assert.equal(cx.detected_stage, null);
const count = (table, id) =>
db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE lane_id = ?`).get(id).n;
assert.equal(count("lane_stage_events", codex.id), 0);
assert.equal(count("lane_features", codex.id), 0);
const cc = lanesLib.getLane(claude.id);
assert.equal(cc.stage, "tests");
assert.ok(count("lane_stage_events", claude.id) > 0);
assert.equal(count("lane_features", claude.id), 1);
assert.equal(lanesLib.clearCodexPipelines(), 0); // idempotent
});
});
+66 -9
View File
@@ -12,6 +12,7 @@ const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-mcp-"));
const FAKE_HOME = path.join(SUITE_ROOT, "home");
@@ -163,21 +164,77 @@ describe("syncMcp — profile seeding", () => {
});
});
describe("syncMcp — .git/info/exclude idempotency", () => {
it("appends .mcp.json once and does not duplicate it on a second call", async () => {
describe("syncMcp — keeping .mcp.json out of git", () => {
// Real repos, asserted through `git status`: git reads info/exclude from the
// COMMON git dir only, which a fake `.git` dir cannot show.
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
// would point these fixture calls at the outer repo.
const git = (cwd, ...args) => {
const env = { ...process.env };
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
delete env[name];
}
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
};
const untracked = (cwd) => git(cwd, "status", "--porcelain").split("\n").filter(Boolean);
function mcpLane(cwd) {
const sourceRepo = path.join(SUITE_ROOT, "src-exclude");
fs.mkdirSync(sourceRepo, { recursive: true });
writeClaudeJson({
[sourceRepo]: { mcpServers: { playwright: { command: "npx", args: [] } } },
});
const lane = makeLane(sourceRepo);
fs.mkdirSync(path.join(lane.cwd, ".git"), { recursive: true });
return { cwd, source_repo: sourceRepo };
}
await laneMcp.syncMcp(lane);
await laneMcp.syncMcp(lane);
function makeRepo() {
const repo = makeLane(null).cwd;
git(repo, "init", "-q");
git(
repo,
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init"
);
return repo;
}
const exclude = fs.readFileSync(path.join(lane.cwd, ".git", "info", "exclude"), "utf8");
const matches = exclude.split("\n").filter((line) => line === ".mcp.json");
assert.equal(matches.length, 1);
it("hides .mcp.json, once, however many times it syncs", async () => {
const repo = makeRepo();
await laneMcp.syncMcp(mcpLane(repo));
await laneMcp.syncMcp(mcpLane(repo));
assert.deepEqual(untracked(repo), []);
const exclude = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
assert.equal(exclude.split("\n").filter((line) => line.includes(".mcp.json")).length, 1);
});
it("hides it in a worktree lane, whose .git is a pointer file", async () => {
const repo = makeRepo();
const wt = path.join(SUITE_ROOT, `wt-${laneSeq}`);
git(repo, "worktree", "add", "-q", "-b", "lane1", wt);
await laneMcp.syncMcp(mcpLane(wt));
assert.deepEqual(untracked(wt), []);
});
it("hides it in a lane that is a subdirectory of its repo", async () => {
const repo = makeRepo();
const sub = path.join(repo, "packages", "app");
fs.mkdirSync(sub, { recursive: true });
await laneMcp.syncMcp(mcpLane(sub));
assert.deepEqual(untracked(repo), []);
});
it("creates no .git in a lane that is not a repo", async () => {
const cwd = makeLane(null).cwd;
await laneMcp.syncMcp(mcpLane(cwd));
assert.ok(fs.existsSync(path.join(cwd, ".mcp.json")));
assert.equal(fs.existsSync(path.join(cwd, ".git")), false);
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* @file Tests scripts/launchd-service.js, the macOS LaunchAgent twin of the
* systemd unit: the plist it writes (KeepAlive, AbandonProcessGroup so lane
* panes survive a restart, the plugin root as WorkingDirectory), that writing
* starts nothing, that a restart re-reads the plist (bootout + bootstrap, not
* kickstart), and that a foreign plist is never touched. `launchctl` is
* stubbed and the platform faked, so this runs on any OS.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-launchd-"));
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME;
const realPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin" });
const svc = require("../../scripts/launchd-service");
const DOMAIN = `gui/${process.getuid()}`;
const JOB = `${DOMAIN}/io.smartgift.agenthub`;
let calls = [];
let loaded = false;
let running = false;
let failBootstrap = false;
let noGui = false;
beforeEach(() => {
calls = [];
loaded = running = failBootstrap = noGui = false;
fs.rmSync(svc.unitPath("agenthub"), { force: true });
svc.__setExecImpl((args) => {
if (args[0] === "print" && args[1] === DOMAIN) return { status: noGui ? 113 : 0 };
calls.push(args.join(" "));
if (args[0] === "print") {
return {
status: loaded ? 0 : 113,
stdout: running ? "\tstate = running\n" : "\tstate = not running\n",
};
}
if (args[0] === "bootstrap") return { status: failBootstrap ? 5 : 0 };
return { status: 0 };
});
});
after(() => {
svc.__reset();
Object.defineProperty(process, "platform", realPlatform);
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
fs.rmSync(TMP_HOME, { recursive: true, force: true });
});
const UNIT = {
name: "agenthub",
description: "AgentHub dashboard server (Claude Code plugin)",
workingDir: "/plugin/root/new & improved",
execStart: "/usr/local/bin/node /plugin/root/new/server/index.js",
argv: ["/usr/local/bin/node", "/plugin/root/new & improved/server/index.js"],
env: { NODE_ENV: "production", PATH: "/usr/bin:/bin" },
};
const plist = () => fs.readFileSync(svc.unitPath("agenthub"), "utf8");
describe("launchd-service — writeUserService", () => {
it("writes a LaunchAgent that restarts forever and spares the lane panes, and starts nothing", () => {
assert.equal(svc.writeUserService(UNIT), "written");
const body = plist();
assert.ok(svc.unitPath("agenthub").startsWith(path.join(TMP_HOME, "Library", "LaunchAgents")));
assert.match(body, /<key>Label<\/key>\s*<string>io\.smartgift\.agenthub<\/string>/);
assert.match(body, /<key>KeepAlive<\/key>\s*<true\/>/);
assert.match(body, /<key>RunAtLoad<\/key>\s*<true\/>/);
assert.match(body, /<key>AbandonProcessGroup<\/key>\s*<true\/>/);
// argv stays split, so a path with a space is one argument; XML escaped.
assert.match(body, /<string>\/plugin\/root\/new &amp; improved\/server\/index\.js<\/string>/);
assert.match(body, /<key>PATH<\/key>\s*<string>\/usr\/bin:\/bin<\/string>/);
assert.deepEqual(calls, [`enable ${JOB}`], "writing must not load or start the job");
});
it("leaves an identical plist alone", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.writeUserService(UNIT), "unchanged");
assert.deepEqual(calls, []);
});
it("never overwrites a plist it did not write", () => {
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(svc.unitPath("agenthub"), "<plist>theirs</plist>");
assert.equal(svc.writeUserService(UNIT), "foreign");
assert.equal(plist(), "<plist>theirs</plist>");
assert.equal(svc.uninstallUserService("agenthub"), "foreign");
assert.ok(fs.existsSync(svc.unitPath("agenthub")));
});
it("is unavailable without a GUI login, and writes nothing", () => {
noGui = true;
assert.equal(svc.systemctlAvailable(), false);
assert.equal(svc.writeUserService(UNIT), "unavailable");
assert.ok(!fs.existsSync(svc.unitPath("agenthub")));
});
});
describe("launchd-service — stale and root", () => {
it("reads the working directory back, escaped path included", () => {
svc.writeUserService(UNIT);
assert.equal(svc.unitRoot("agenthub"), UNIT.workingDir);
assert.equal(svc.unitIsStale("agenthub", UNIT.workingDir), false);
assert.equal(svc.unitIsStale("agenthub", "/plugin/root/newer"), true);
});
it("calls neither a missing nor a foreign plist stale", () => {
assert.equal(svc.unitIsStale("agenthub", "/x"), false);
fs.mkdirSync(path.dirname(svc.unitPath("agenthub")), { recursive: true });
fs.writeFileSync(
svc.unitPath("agenthub"),
"<key>WorkingDirectory</key><string>/elsewhere</string>"
);
assert.equal(svc.unitIsStale("agenthub", "/x"), false);
assert.equal(svc.unitRoot("agenthub"), "/elsewhere", "the pruner still must not delete it");
});
});
describe("launchd-service — restart, install, uninstall", () => {
it("restarts by bootout + bootstrap, so the new plist is what runs", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.restartUserService("agenthub"), "restarted");
assert.deepEqual(calls, [`bootout ${JOB}`, `bootstrap ${DOMAIN} ${svc.unitPath("agenthub")}`]);
});
it("reports a failed bootstrap, so the caller can fall back", () => {
failBootstrap = true;
assert.equal(svc.restartUserService("agenthub"), "failed");
});
it("install loads a new plist, and only kickstarts one already loaded", () => {
assert.equal(svc.installUserService(UNIT), "written");
assert.ok(calls.includes(`bootstrap ${DOMAIN} ${svc.unitPath("agenthub")}`));
calls = [];
loaded = true;
assert.equal(svc.installUserService(UNIT), "unchanged");
assert.ok(calls.includes(`kickstart ${JOB}`));
assert.ok(!calls.some((c) => c.startsWith("bootout")), "a running dashboard is not bounced");
});
it("uninstall unloads and deletes our plist", () => {
svc.writeUserService(UNIT);
calls = [];
assert.equal(svc.uninstallUserService("agenthub"), "removed");
assert.deepEqual(calls, [`bootout ${JOB}`]);
assert.ok(!fs.existsSync(svc.unitPath("agenthub")));
assert.equal(svc.uninstallUserService("agenthub"), "absent");
});
it("is active only when launchd says the job is running", () => {
assert.equal(svc.unitIsActive("agenthub"), false);
loaded = true;
assert.equal(svc.unitIsActive("agenthub"), false);
running = true;
assert.equal(svc.unitIsActive("agenthub"), true);
});
});
describe("service-manager", () => {
it("hands out the LaunchAgent on macOS", () => {
delete require.cache[require.resolve("../../scripts/service-manager")];
assert.equal(require("../../scripts/service-manager").kind, "launchd");
});
});
+64 -29
View File
@@ -8,6 +8,7 @@
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
@@ -144,50 +145,84 @@ describe("router-apply — Claude lane", () => {
});
describe("router-apply — the written file must survive a lane reset", () => {
it("excludes settings.local.json from git, so `git clean -fd` cannot delete it", () => {
// Real repos, asserted through `git status`: a fake `.git` dir let a test pass
// while git itself never read the exclude file it checked.
const REPO = path.join(SUITE_ROOT, "repo");
// A pre-commit hook running this suite exports GIT_INDEX_FILE & co., which
// would point these fixture calls at the outer repo.
const git = (cwd, ...args) => {
const env = { ...process.env };
for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) {
delete env[name];
}
return execFileSync("git", args, { cwd, env, encoding: "utf8" });
};
const untracked = (cwd) =>
git(cwd, "status", "--porcelain", "--untracked-files=all", "--ignored=no")
.split("\n")
.filter(Boolean);
// A machine-wide ignore (Claude Code adds one for settings.local.json) would
// hide the very file these tests look for.
beforeEach(() => {
process.env.GIT_CONFIG_GLOBAL = "/dev/null";
process.env.GIT_CONFIG_NOSYSTEM = "1";
fs.rmSync(REPO, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
git(REPO, "init", "-q");
git(
REPO,
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init"
);
});
const laneAt = (cwd) => ({ id: 7, cwd, tool: "claude" });
it("hides settings.local.json and its backups, so `git clean -fd` cannot delete them", () => {
// `agenthub lanes reset` runs `git clean -fd` (server/lib/worktree.js).
// Without -x that spares IGNORED files only — an untracked settings file
// would be deleted, silently detaching the lane from its router. The same
// entry also stops an API key from being committable.
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
applyRouterToLane(laneAt(REPO), name);
applyRouterToLane(laneAt(REPO), "native"); // a second write leaves a backup
assert.deepEqual(untracked(REPO), []);
});
it("excludes it for a native lane too, not only a lane on a router", () => {
// The blanked connection pair is load-bearing in its own right: losing it
// to `git clean -fd` lets a USER-level router take over a lane that
// explicitly asked for native.
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
applyRouterToLane(claudeLane(), "native");
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
it("hides them in a worktree lane, whose exclude file is the COMMON dir's", () => {
// git reads info/exclude from the common git dir only; a copy under
// .git/worktrees/<name>/info/ is never consulted.
const wt = path.join(SUITE_ROOT, "wt");
fs.rmSync(wt, { recursive: true, force: true });
git(REPO, "worktree", "add", "-q", "-b", "lane1", wt);
const { name } = saveNineRouter();
applyRouterToLane(laneAt(wt), name);
applyRouterToLane(laneAt(wt), "native");
assert.deepEqual(untracked(wt), []);
});
it("resolves the real git dir when .git is a file, as it is in every worktree lane", () => {
// A linked worktree's `.git` is a pointer file; joining ".git/info" onto it
// would be a path that cannot exist.
const gitDir = path.join(SUITE_ROOT, "realgit", "worktrees", "lane1");
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(LANE_CWD, ".git"), `gitdir: ${gitDir}\n`);
it("hides them in a lane that is a subdirectory of its repo", () => {
const sub = path.join(REPO, "packages", "app");
fs.mkdirSync(sub, { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
const exclude = fs.readFileSync(path.join(gitDir, "info", "exclude"), "utf8");
assert.match(exclude, /^\.claude\/settings\.local\.json$/m);
applyRouterToLane(laneAt(sub), name);
applyRouterToLane(laneAt(sub), "native");
assert.deepEqual(untracked(REPO), []);
});
it("adds the entry once, however many times a router is applied", () => {
fs.mkdirSync(path.join(LANE_CWD, ".git", "info"), { recursive: true });
const { name } = saveNineRouter();
applyRouterToLane(claudeLane(), name);
applyRouterToLane(claudeLane(), "native");
applyRouterToLane(laneAt(REPO), name);
applyRouterToLane(laneAt(REPO), "native");
const exclude = fs.readFileSync(path.join(LANE_CWD, ".git", "info", "exclude"), "utf8");
const exclude = fs.readFileSync(path.join(REPO, ".git", "info", "exclude"), "utf8");
assert.equal(exclude.split("\n").filter((l) => l.includes("settings.local.json")).length, 1);
});
+16 -12
View File
@@ -480,9 +480,22 @@ 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
// Codex lanes carry no pipeline. Older builds inferred one and wrote a stage
// block into their AGENTS.md; take both back out. Idempotent, every start.
try {
const lanesLib = require("./lib/lanes");
const { removeStageDoc } = require("./lib/lane-agents");
const wiped = lanesLib.clearCodexPipelines();
if (wiped > 0) console.log(`Cleared leftover pipeline data from ${wiped} codex lane(s)`);
for (const lane of lanesLib.listLanes().filter((l) => l.tool === "codex")) {
if (removeStageDoc(lane.cwd))
console.log(`Removed the AgentHub stage block from ${lane.cwd}/AGENTS.md`);
}
} catch (err) {
console.warn("codex pipeline cleanup failed:", err.message);
}
// Codex lane status. Codex emits no hooks at all, so its rollout journals on
// disk are the only signal a `codex` lane is working. `broadcastLane` (not the
// bare `broadcast(type, data)` above) is the single-lane push the hook route
// uses.
try {
@@ -491,15 +504,6 @@ 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
// `agenthub 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
+22 -222
View File
@@ -1,26 +1,16 @@
/**
* @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:
* @file Codex lane status mirror. Codex emits no hooks, so nothing else can
* tell the dashboard that a `codex` lane's agent is working. Its on-disk
* signal 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`. A written detection
* then ALSO advances the lane's declared `stage` to that node, without
* evidence: codex lanes are the one place where nothing else can move the
* pipeline map, so the node renders `passed-no-evidence` (amber) rather than
* `done`, and codex-nudge.js asks the session for the real evidence. 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.
* This module maps each journal to the `codex` lane owning its cwd and mirrors
* idle↔running from how recently the journal was written. It reads nothing but
* the first line (for the cwd) and never touches a lane's stage — codex lanes
* carry no pipeline. Fail-safe like workflow-ingest.js: a malformed journal 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
@@ -35,70 +25,10 @@ const path = require("node:path");
const os = require("node:os");
const { getCodexHome } = require("./codex-home");
// `*** 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) } }];
}
/** Every rollout journal under sessions/, newest mtime first, capped at MAX_FILES. */
function rolloutFiles() {
const out = [];
@@ -151,101 +81,29 @@ function rolloutCwd(file) {
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();
/** Test seam: forget every cached cwd. */
function __resetCache() {
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.
// Keyed by file path, so in a process that runs for months it 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 entries.
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);
}
if (cwdCache.size <= MAX_TRACKED) return;
for (const key of cwdCache.keys()) if (!seen.has(key)) cwdCache.delete(key);
}
/**
* 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.
* One poll pass: mirror each codex lane's idle↔running from its journals.
*
* @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 {
@@ -266,67 +124,12 @@ function ingestCodexOnce({ broadcast } = {}) {
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
if (!lane) continue;
const firstSight = !offsets.has(file.path);
// An mtime in the FUTURE (clock stepped back, NTP correction, a restore that
// preserved timestamps) makes the raw difference negative, which reads as
// "written milliseconds ago" and would let a finished thread's journal
// replay as live work. A file that claims to be from the future is not
// evidence of an agent working now, so treat it as idle.
// "written milliseconds ago". A file that claims to be from the future is
// not evidence of an agent working now, so treat it as idle.
const age = Date.now() - file.mtime;
const active = age >= 0 && age < 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;
// Auto-advance: a codex session has no hook and rarely calls `agenthub
// stage` on its own, so without this its card sits at `idle` forever
// while the amber badge is the only thing that ever moves. Gated on
// recordDetection having WRITTEN, so it inherits that function's
// forward-only + declared-wins + TTL guards for free. Deliberately
// passes NO evidence: the node renders `passed-no-evidence` (amber),
// never `done` — detection sees a tool call, never its outcome. The
// last node has no detect rules in any template, so this can never
// auto-declare a lane `done`.
if (changed) {
lanesLib.setStage(lane.id, { stage: detection.nodeId, keepDetection: true });
}
} 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
@@ -340,12 +143,11 @@ function ingestCodexOnce({ broadcast } = {}) {
) {
try {
lanesLib.updateLane(lane.id, { status: next });
changed = true;
touched.add(lane.id);
} catch {
/* as above */
/* lane deleted mid-pass, or sqlite busy — a status mirror is never worth throwing for */
}
}
if (changed) touched.add(lane.id);
}
for (const id of touched) {
@@ -361,8 +163,7 @@ function ingestCodexOnce({ broadcast } = {}) {
/**
* 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.
* beat keeps a codex card's running/idle within one beat of live.
*/
function startCodexPoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_CODEX_POLL_MS
@@ -380,9 +181,8 @@ function startCodexPoll(broadcast) {
}
module.exports = {
eventsFromPayload,
rolloutCwd,
ingestCodexOnce,
startCodexPoll,
__resetOffsets,
__resetCache,
};
-103
View File
@@ -1,103 +0,0 @@
/**
* @file Safety-net reminder for codex lanes sitting at a stage nobody has
* backed with evidence. `codex-ingest.js` auto-advances a codex lane's stage
* from its rollout journal — that keeps the pipeline map moving, but an
* inferred stage carries no evidence, so its node renders
* `passed-no-evidence` (amber) rather than `done`. Only the session's own
* `agenthub stage <node> --evidence "..."` call can supply that, and a codex
* session has no hook or wrapper that does it for it.
*
* This module never writes `stage`, `stages` or `detected_stage` itself. 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 the evidence 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 stage already nudged for, so the 60s sweep
// doesn't retype the same reminder every pass. Cleared implicitly: once the
// session declares with evidence, or the lane moves to another node, 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 CURRENT stage has been held for
* at least NUDGE_DELAY_MS with no evidence recorded, and hasn't already been
* nudged for that exact 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 {
// No record under the current stage means the lane never entered a
// pipeline node (a fresh lane sits at `idle`) — nothing to ask about.
const record = (lane.stages || {})[lane.stage];
if (!record || record.evidence) {
nudgedFor.delete(lane.id);
continue;
}
const enteredAt = Date.parse(record.enteredAt);
if (!Number.isFinite(enteredAt) || Date.now() - enteredAt < NUDGE_DELAY_MS) continue;
if (nudgedFor.get(lane.id) === lane.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,
`[agenthub] Stage "${lane.stage}" was inferred and has no evidence — run: agenthub stage ${lane.stage} --tool codex --evidence "..."`
);
nudgedFor.set(lane.id, lane.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,
};
+59 -2
View File
@@ -851,8 +851,13 @@ async function listTree(dir, { subdir = "" } = {}) {
* applied is always git's own text of the current tree — never a patch a
* request carried. Null when no hunk matches (the tree moved on, or the file
* is a secret, binary or too large to read whole).
*
* `block` narrows it to one CHANGE BLOCK of that hunk — a run of consecutive
* +/- lines. git merges changes up to 2×context lines apart into one hunk, so
* without this a single Undo/Keep acted on every nearby edit at once (#50).
* `reverse` says which side the patch will be applied to (see `onlyBlock`).
*/
async function hunkPatch(dir, file, header, context) {
async function hunkPatch(dir, file, header, context, { block, reverse = false } = {}) {
if (!header || (await isSecretOnDisk(dir, file))) return null;
const u = `-U${normalizeContext(context)}`;
const raw = await readCapped(
@@ -871,7 +876,59 @@ async function hunkPatch(dir, file, header, context) {
.slice(at + 1)
.split(/^(?=@@ )/m)
.find((h) => h.slice(0, h.indexOf("\n")) === header);
return hunk ? head + (hunk.endsWith("\n") ? hunk : `${hunk}\n`) : null;
if (!hunk) return null;
const body = block === undefined ? hunk : onlyBlock(hunk, block, reverse);
return body ? head + (body.endsWith("\n") ? body : `${body}\n`) : null;
}
/**
* One hunk's text cut down to its `block`-th change block, the way an edited
* `git add -p` hunk is: every OTHER block is folded into the side the patch
* will be checked against, so that side stays byte-for-byte the hunk's own and
* `git apply` finds it exactly where git put it.
*
* - forward (stage, applied to the index = the OLD side): another block's
* "-" lines become context and its "+" lines are dropped;
* - reverse (undo, `apply -R` on the working tree = the NEW side): its "+"
* lines become context and its "-" lines are dropped.
*
* A "\ No newline" marker follows the line it annotates, kept or dropped with
* it. The header's counts are recomputed; its starts do not move, because the
* checked side is unchanged. Null when the hunk has no such block.
*/
function onlyBlock(hunk, block, reverse) {
if (!Number.isInteger(block) || block < 0) return null;
const [header, ...lines] = hunk.replace(/\n$/, "").split("\n");
const m = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
if (!m) return null;
const out = [];
let index = -1;
let inBlock = false;
let keptPrev = false;
let oldCount = 0;
let newCount = 0;
for (const line of lines) {
const mark = line[0];
if (mark === "\\") {
if (keptPrev) out.push(line);
continue;
}
const change = mark === "+" || mark === "-";
if (change && !inBlock) index += 1;
inBlock = change;
let kept = line;
if (change && index !== block) {
const fold = reverse ? "+" : "-";
kept = mark === fold ? ` ${line.slice(1)}` : null;
}
keptPrev = kept !== null;
if (kept === null) continue;
out.push(kept);
if (kept[0] !== "+") oldCount += 1;
if (kept[0] !== "-") newCount += 1;
}
if (block > index) return null;
return [`@@ -${m[1]},${oldCount} +${m[2]},${newCount} @@${m[3]}`, ...out].join("\n") + "\n";
}
/**
+61
View File
@@ -0,0 +1,61 @@
/**
* @file Keeps files AgentHub writes into a lane's directory out of git, through
* the one `info/exclude` git actually reads for that lane. That is the COMMON
* git dir's, not the lane's own: a linked worktree — every managed lane — has a
* private `.git/worktrees/<name>/`, and git never reads an exclude file there.
* A lane that is a subdirectory of its repo has no `.git` of its own at all.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
// Hook variables that would point git at ANOTHER repo (the one whose hook, or
// shell, started us) — the same set `worktree.js`'s git() scrubs. With
// GIT_DIR set, `rev-parse` would name that repo and the entry land there.
const REPO_VARS = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_PREFIX",
"GIT_NAMESPACE",
];
/**
* Idempotently exclude `relPaths` (relative to `laneDir`), each anchored to the
* lane's own path so the entry hides nothing outside it. Best effort: a lane
* that is not a repo, or a git dir that cannot be written, is left alone —
* never a reason to fail the write that needed the exclude.
*
* @param {string} laneDir
* @param {string[]} relPaths e.g. [".mcp.json", ".claude/.cc-config-backups/"]
*/
function excludeFromGit(laneDir, relPaths) {
try {
const env = { ...process.env };
for (const name of REPO_VARS) delete env[name];
const [commonDir, prefix = ""] = execFileSync(
"git",
["rev-parse", "--git-common-dir", "--show-prefix"],
{ cwd: laneDir, env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
).split("\n");
const lines = relPaths.map((rel) => `/${prefix}${rel}`);
const excludePath = path.join(path.resolve(laneDir, commonDir), "info", "exclude");
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
const present = new Set(existing.split("\n"));
const missing = lines.filter((line) => !present.has(line));
if (missing.length === 0) return;
if (existing && !existing.endsWith("\n")) existing += "\n";
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
} catch {
/* not a repo, or unwritable — see above */
}
}
module.exports = { excludeFromGit };
+4 -1
View File
@@ -390,11 +390,14 @@ async function runGitAction(dir, action, body = {}) {
if (target.code === "??") {
throw bad("EUNTRACKED", "an untracked file has no hunks; stage or discard the whole file");
}
// `block` (optional) picks one change block inside the hunk (#50). It is
// an index into git's own fresh text, never text from the request.
const patch = await hunkPatch(
dir,
target.path,
typeof body.hunk === "string" ? body.hunk : "",
body.context
body.context,
body.block === undefined ? {} : { block: body.block, reverse: action === "discard-hunk" }
);
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
// The patch names paths from the repo root, so it is applied there.
+32 -46
View File
@@ -1,6 +1,6 @@
/**
* @file Installs a lane's driving instructions. For a `claude` lane that is
* the ship-feature-lane pipeline's agent templates
* @file Installs a `claude` lane's driving instructions: the
* ship-feature-lane pipeline's agent templates
* (qc-local, senior-gate-reviewer) into a lane's own .claude/agents/, so a
* driving session can launch them by the BARE subagent_type name. Source is
* the repo's own `agents/` — the same files the agenthub plugin ships, where
@@ -20,50 +20,36 @@ const TEMPLATE_DIR = path.join(__dirname, "..", "..", "agents");
const TEMPLATE_FILES = ["qc-local.md", "senior-gate-reviewer.md"];
const EXCLUDE_LINE = ".claude/agents/";
// Codex reads no `.claude/agents/`, so for a codex lane the useful thing to
// install is the one instruction it DOES read: AGENTS.md, the project doc it
// loads from the working directory. Marker-delimited so a reinstall replaces
// exactly this block and never the user's own prose, and appended rather than
// written over an existing file.
// The block older builds wrote into a codex lane's AGENTS.md. Codex lanes no
// longer carry a pipeline, so `removeStageDoc` takes it back out.
const STAGE_BLOCK_START = "<!-- agenthub:stage:start -->";
const STAGE_BLOCK_END = "<!-- agenthub:stage:end -->";
const STAGE_BLOCK = `${STAGE_BLOCK_START}
## Declare your lane stage as you work
This directory is watched by AgentHub. Its pipeline map advances on its own by
*inferring* your stage from your tool calls, but an inference carries no
evidence — the node stays amber, never green. Only your own declaration does:
\`\`\`bash
agenthub stage <stage> --tool codex --evidence "<what is actually done>"
\`\`\`
- Call it at every real transition — starting to plan, starting to implement,
running tests, opening the PR — not once at the end.
- \`--tool codex\` matters: one directory can back both a \`claude\` lane and a
\`codex\` lane, and without a tool the command fails rather than guess. Inside
a pane the dashboard started you need nothing — it carries \`AGENTHUB_LANE_ID\`.
- \`agenthub stage\` warns on stderr and names the valid nodes if a stage matches
none of this lane's pipeline template.
- If it reports that no lane owns this directory, skip the stage calls and
carry on. Stage reporting is reporting, never control flow.
- A one-line \`[agenthub]\` reminder may be typed straight into this pane asking for
evidence on the current stage. That is the dashboard, not the user — treat it
as the same instruction as this section and make the real call.
${STAGE_BLOCK_END}`;
/** Append the stage block to `<cwd>/AGENTS.md`, or replace an existing one. */
function installStageDoc(cwd) {
/**
* Remove AgentHub's stage block from `<cwd>/AGENTS.md`, leaving every other
* line as it was. A file left with nothing else in it was AgentHub's own
* creation, so it is deleted. Never throws.
*
* @returns {boolean} whether the file changed
*/
function removeStageDoc(cwd) {
const file = path.join(cwd, "AGENTS.md");
const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
const start = existing.indexOf(STAGE_BLOCK_START);
const end = existing.indexOf(STAGE_BLOCK_END);
const next =
start !== -1 && end > start
? existing.slice(0, start) + STAGE_BLOCK + existing.slice(end + STAGE_BLOCK_END.length)
: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${STAGE_BLOCK}\n`;
fs.writeFileSync(file, next);
return "AGENTS.md";
try {
const doc = fs.readFileSync(file, "utf8");
const start = doc.indexOf(STAGE_BLOCK_START);
const end = doc.indexOf(STAGE_BLOCK_END, start);
if (start === -1 || end === -1) return false;
const rest = (
doc.slice(0, start).trimEnd() +
"\n\n" +
doc.slice(end + STAGE_BLOCK_END.length).trimStart()
).trim();
if (rest) fs.writeFileSync(file, `${rest}\n`);
else fs.rmSync(file);
return true;
} catch {
return false; // no file, or unreadable — nothing of ours to take out
}
}
/** The dir shared across every worktree of a repo — where info/exclude
@@ -80,8 +66,8 @@ async function commonGitDir(cwd) {
* any existing copy — this is a reinstall, not a merge) and idempotently
* git-exclude that directory in the shared common git dir.
*
* A `codex` lane gets the AGENTS.md stage block instead — Codex never reads
* `.claude/agents/`, and its own working copy is the only channel it does read.
* A `codex` lane gets nothing: Codex never reads `.claude/agents/`, and codex
* lanes carry no pipeline to drive.
*
* @param {{cwd: string, tool?: string}} lane
* @returns {Promise<{installed: string[]}>}
@@ -93,7 +79,7 @@ async function installAgents(lane) {
});
}
if (lane.tool === "codex") return { installed: [installStageDoc(lane.cwd)] };
if (lane.tool === "codex") return { installed: [] };
const dest = path.join(lane.cwd, ".claude", "agents");
fs.mkdirSync(dest, { recursive: true });
@@ -116,4 +102,4 @@ async function installAgents(lane) {
return { installed };
}
module.exports = { installAgents };
module.exports = { installAgents, removeStageDoc };
+5 -19
View File
@@ -9,8 +9,8 @@
* logins — a QA account only needs to log in once per machine).
*
* Deliberately does NOT write any permission or settings.local.json content
* — see the F1 design spec's Scope section for why. Plain file I/O; no git
* calls, unlike lane-sync.js (E2) or lane-agents.js (E3).
* — see the F1 design spec's Scope section for why. Plain file I/O apart
* from the one `git rev-parse` behind `excludeFromGit`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +18,8 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { excludeFromGit } = require("./git-exclude");
/** Read the source repo's mcpServers from ~/.claude.json. Throws
* ENOMCPCONFIG if the file is missing or the project has none declared —
* a lane with zero MCP servers synced would silently break Stage 3/6 much
@@ -99,22 +101,6 @@ function seedProfiles(sourceRepo, laneDir) {
return seeded;
}
/** Idempotently append a line to <laneDir>/.git/info/exclude — same
* read-existing-then-append-if-missing pattern E2/E3 already use for
* .git/info/attributes and .git/info/exclude. .mcp.json sits at the lane
* root (not inside .git), so this needs no --git-common-dir resolution —
* the exclude file itself is always local to this lane's own working copy. */
function excludeFromGit(laneDir, line) {
const excludePath = path.join(laneDir, ".git", "info", "exclude");
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
const lines = existing.split("\n").filter(Boolean);
if (!lines.includes(line)) {
lines.push(line);
fs.writeFileSync(excludePath, lines.join("\n") + "\n");
}
}
/**
* @param {{cwd: string, source_repo: string}} lane
* @returns {{servers: string[], profilesSeeded: string[]}}
@@ -129,7 +115,7 @@ async function syncMcp(lane) {
path.join(lane.cwd, ".mcp.json"),
JSON.stringify({ mcpServers: relocated }, null, 2) + "\n"
);
excludeFromGit(lane.cwd, ".mcp.json");
excludeFromGit(lane.cwd, [".mcp.json"]);
const profilesSeeded = seedProfiles(sourceRepo, lane.cwd);
+31 -11
View File
@@ -410,13 +410,7 @@ function reservedPorts(excludeLaneId = null) {
* of where the agent now says it is has not been passed for this pass; it has
* to be re-earned. Records under a key matching no node in the pipeline are
* left untouched — their position is unknown, so they cannot be proven ahead.
*
* `keepDetection` is the ONE exception to that clearing, and exists for exactly
* one caller: codex-ingest.js, which advances a codex lane's stage FROM its own
* detection. There the detection is not stale evidence of something the
* declaration superseded — it is the reason for the declaration, and the card
* has to keep saying so, or an inferred stage becomes indistinguishable from
* one the agent actually claimed.
*/
/**
* Append one row to the stage log — the history `lanes.stages` cannot keep,
@@ -472,7 +466,7 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
}
}
function setStage(id, { stage, status, evidence, note, result, keepDetection } = {}) {
function setStage(id, { stage, status, evidence, note, result } = {}) {
const lane = getLane(id);
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
const next = stage || lane.stage;
@@ -495,9 +489,7 @@ function setStage(id, { stage, status, evidence, note, result, keepDetection } =
};
db.prepare(
`UPDATE lanes SET stage = ?, stage_since = ?, status = ?, stages = ?, notes = ?, updated_at = ?${
changed && !keepDetection
? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL"
: ""
changed ? ", detected_stage = NULL, detected_signal = NULL, detected_at = NULL" : ""
}
WHERE id = ?`
).run(
@@ -864,6 +856,33 @@ function purgeLaneSessions(laneId) {
return result;
}
/**
* Codex lanes carry no pipeline, but older builds inferred and advanced one for
* them. Wipe what those builds left — the stage columns, the stage log and any
* feature records — so nothing stale survives into the Workflows statistics or
* resurfaces if a view ever reads it. Idempotent; run at every server start.
*
* @returns {number} how many codex lanes still carried pipeline data
*/
function clearCodexPipelines() {
const CODEX = "SELECT id FROM lanes WHERE tool = 'codex'";
let changed = 0;
db.transaction(() => {
changed = db
.prepare(
`UPDATE lanes SET stage = 'idle', stage_since = NULL, stages = '{}',
detected_stage = NULL, detected_signal = NULL, detected_at = NULL
WHERE tool = 'codex' AND (stage <> 'idle' OR stages <> '{}' OR detected_stage IS NOT NULL
OR id IN (SELECT lane_id FROM lane_stage_events)
OR id IN (SELECT lane_id FROM lane_features))`
)
.run().changes;
db.prepare(`DELETE FROM lane_stage_events WHERE lane_id IN (${CODEX})`).run();
db.prepare(`DELETE FROM lane_features WHERE lane_id IN (${CODEX})`).run();
})();
return changed;
}
module.exports = {
DEAD_SEC,
VALID_TOOLS,
@@ -875,6 +894,7 @@ module.exports = {
updateLane,
deleteLane,
setStage,
clearCodexPipelines,
setRouterProfile,
lanesByRouter,
recordDetection,
+5 -40
View File
@@ -34,6 +34,7 @@ const path = require("node:path");
const routers = require("./ai-routers");
const { patchSettings } = require("./cc-mutate");
const { excludeFromGit } = require("./git-exclude");
const { getCodexHome } = require("./codex-home");
const { lanesByRouter, setRouterProfile } = require("./lanes");
@@ -72,26 +73,6 @@ function readOrNull(name) {
}
}
/**
* The lane's own git directory. For a normal clone that is `<cwd>/.git`; for a
* LINKED WORKTREE — which is what every managed lane is — `.git` is a pointer
* FILE, and joining `info/exclude` onto it names a path that cannot exist.
*
* @returns {string|null} absolute git dir, or null when this is not a repo
*/
function gitDirOf(laneDir) {
const dotGit = path.join(laneDir, ".git");
let stat;
try {
stat = fs.statSync(dotGit);
} catch {
return null; // not a git repo — a perfectly normal adopted directory
}
if (stat.isDirectory()) return dotGit;
const match = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
return match ? path.resolve(laneDir, match[1].trim()) : null;
}
/**
* Idempotently keep `.claude/settings.local.json` out of git, for two reasons
* that both matter:
@@ -101,28 +82,12 @@ function gitDirOf(laneDir) {
* is deleted and the lane silently loses its router.
* - The file holds an API key. Untracked means `git add -A` can commit it.
*
* Best effort: a lane that is not a repo, or a git dir that cannot be written,
* must not fail an otherwise-successful attach.
* The backup directory beside it, for the same reason plus one more: those
* copies hold the API key too, and they are what a user sees as a dozen
* untracked files appearing in their project while they switch routers.
*/
function excludeSettingsFromGit(laneDir) {
// The backup directory beside it, for the same reason plus one more: those
// copies hold the API key too, and they are what a user sees as a dozen
// untracked files appearing in their project while they switch routers.
const lines = [".claude/settings.local.json", ".claude/.cc-config-backups/"];
try {
const gitDir = gitDirOf(laneDir);
if (!gitDir) return;
const excludePath = path.join(gitDir, "info", "exclude");
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
const present = new Set(existing.split("\n"));
const missing = lines.filter((line) => !present.has(line));
if (missing.length === 0) return;
if (existing && !existing.endsWith("\n")) existing += "\n";
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
} catch {
/* best effort — see above */
}
excludeFromGit(laneDir, [".claude/settings.local.json", ".claude/.cc-config-backups/"]);
}
/**
+5
View File
@@ -686,6 +686,11 @@ const paths = {
description:
"stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.",
},
block: {
type: "integer",
description:
"stage-hunk, discard-hunk (optional); act on only the block-th run of consecutive +/- lines inside that hunk — the other runs are folded into context. Out of range is 409 EHUNKGONE.",
},
context: {
type: "integer",
description: "stage-hunk, discard-hunk; the context the hunk was read at.",