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
+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/"]);
}
/**