agent-coord-mcp 0.26.29 → 0.26.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,6 +68,22 @@ export function isTmuxKind(transport) {
68
68
  export function isLocallyProbeable(transport) {
69
69
  return transport === TMUX_PUSH;
70
70
  }
71
+ /**
72
+ * ⟨q-f995c3c7⟩ THE PANE THIS PROCESS IS RUNNING IN, read from ITS OWN ENVIRONMENT — never from a
73
+ * caller's argument, which is what would make it spoofable.
74
+ *
75
+ * The first-claim guard's same-pane exception asked `process.env.TMUX_PANE` only, and a herdr seat
76
+ * has no TMUX_PANE: it has HERDR_PANE_ID. So a herdr seat restarting into its OWN pane could never
77
+ * satisfy the exception, and every one of them was refused against its own leftover marker. Measured
78
+ * 2026-09-23: 6 of 6 seats, cleared only by moving the markers aside by hand.
79
+ */
80
+ export function ownPaneTarget(transport, env = process.env) {
81
+ if (transport === HERDR)
82
+ return env.HERDR_PANE_ID || undefined;
83
+ if (isLocallyProbeable(transport))
84
+ return env.TMUX_PANE || undefined;
85
+ return undefined;
86
+ }
71
87
  /**
72
88
  * Is liveness for this marker decided by the registry heartbeat rather than by a
73
89
  * local pid? True only for the remote kind, whose pid is 0 on a foreign host.
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/transports/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;GAMG;AACH;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAoB,CAAC;AAC9C,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAA2B,CAAC;AAC5D,MAAM,CAAC,MAAM,KAAK,GAAG,OAAgB,CAAC;AAItC,MAAM,CAAC,MAAM,eAAe,GAA6B,CAAC,gBAAgB,EAAE,KAAK,CAAU,CAAC;AAE5F;;;;;;;;GAQG;AACH,MAAM,UAAU,GAAG,IAAI,GAAG,CAAS,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAElE,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,SAA6B;IACtD,OAAO,SAAS,KAAK,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAA6B;IAC9D,OAAO,SAAS,KAAK,SAAS,CAAC;AACjC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,SAA6B;IAC5D,OAAO,SAAS,KAAK,gBAAgB,CAAC;AACxC,CAAC;AAyDD;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,MAAsD;IAC7E,OAAO,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,UAAU,CAAC;AAC5C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAmB,MAAS,EAAE,MAA0B;IAChF,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACxC,OAAO,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;AACnD,CAAC;AAeD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,CAAU,CAAC;AAGnE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,cAAc,GAAwC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;AAC3I,MAAM,CAAC,MAAM,aAAa,GAAwC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;AAKxJ,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,SAAS,EAAE,eAAe,CAAU,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/transports/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;GAMG;AACH;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAoB,CAAC;AAC9C,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAA2B,CAAC;AAC5D,MAAM,CAAC,MAAM,KAAK,GAAG,OAAgB,CAAC;AAItC,MAAM,CAAC,MAAM,eAAe,GAA6B,CAAC,gBAAgB,EAAE,KAAK,CAAU,CAAC;AAE5F;;;;;;;;GAQG;AACH,MAAM,UAAU,GAAG,IAAI,GAAG,CAAS,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAElE,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,SAA6B;IACtD,OAAO,SAAS,KAAK,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAA6B;IAC9D,OAAO,SAAS,KAAK,SAAS,CAAC;AACjC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,aAAa,CAAC,SAA6B,EAAE,GAAG,GAAsB,OAAO,CAAC,GAAG;IAC/F,IAAI,SAAS,KAAK,KAAK;QAAE,OAAO,GAAG,CAAC,aAAa,IAAI,SAAS,CAAC;IAC/D,IAAI,kBAAkB,CAAC,SAAS,CAAC;QAAE,OAAO,GAAG,CAAC,SAAS,IAAI,SAAS,CAAC;IACrE,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,SAA6B;IAC5D,OAAO,SAAS,KAAK,gBAAgB,CAAC;AACxC,CAAC;AAsED;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,MAAsD;IAC7E,OAAO,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,UAAU,CAAC;AAC5C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAmB,MAAS,EAAE,MAA0B;IAChF,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACxC,OAAO,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;AACnD,CAAC;AAeD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,CAAU,CAAC;AAGnE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,cAAc,GAAwC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;AAC3I,MAAM,CAAC,MAAM,aAAa,GAAwC,MAAM,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;AAKxJ,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,SAAS,EAAE,eAAe,CAAU,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.26.29",
3
+ "version": "0.26.31",
4
4
  "description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,18 +93,25 @@ function rel(file) {
93
93
  return file ? `test/${path.basename(file)}` : "(unattributed)";
94
94
  }
95
95
 
96
- if (failures.length) {
96
+ // ⟨q-4b2362a8⟩ — A FAILING TEST MUST NOT MASK STRUCTURAL DRIFT. This used to report the failures and
97
+ // exit, so an unrecorded test file stayed invisible behind any red: #393's `bind-list.test.mjs` was
98
+ // missing from the record for days, hidden behind the quiet-fleet failure of merge-window. The
99
+ // failures are still the headline — a count over a failing suite means little — but the drift that
100
+ // does NOT depend on the counts (a file nobody recorded, a record entry with no file) is reported in
101
+ // the same run, because one red hiding another is how both survive.
102
+ const failed = failures.length > 0;
103
+ if (failed) {
97
104
  console.error(`[check-test-count] ${failures.length} test(s) FAILED — the count is not the story here:`);
98
105
  for (const f of failures) console.error(` ${f}`);
99
- process.exit(1);
100
106
  }
101
107
 
102
108
  // `=0` disables; any other override asserts the old single global total.
103
- if (globalOverride === 0) {
109
+ // Both early exits are skipped on a failing suite: a green there would report a pass over a red run.
110
+ if (globalOverride === 0 && !failed) {
104
111
  console.log(`[check-test-count] disabled by AGENT_COORD_EXPECTED_TESTS=0 — ${total} test(s) passed, unchecked`);
105
112
  process.exit(0);
106
113
  }
107
- if (globalOverride !== null && Number.isFinite(globalOverride)) {
114
+ if (globalOverride !== null && Number.isFinite(globalOverride) && !failed) {
108
115
  if (total !== globalOverride) {
109
116
  console.error(`[check-test-count] FAIL — ${total} tests passed, override expected ${globalOverride}.`);
110
117
  process.exit(1);
@@ -115,11 +122,15 @@ if (globalOverride !== null && Number.isFinite(globalOverride)) {
115
122
 
116
123
  // ── the real comparison, per file ──────────────────────────────────────────
117
124
  const drifted = [];
125
+ // STRUCTURAL drift does not depend on the counts — which file is recorded at all, which record entry
126
+ // has no file. It is the half that still means something when the suite is red (⟨q-4b2362a8⟩).
127
+ const structural = [];
118
128
  for (const f of files) {
119
129
  const want = expected[f];
120
130
  const got = actual.get(f) ?? 0;
121
131
  if (want === undefined) {
122
- drifted.push(`${f}: ran ${got} test(s) and is NOT in the record — a new test file must be recorded in the same commit`);
132
+ const line = `${f}: ran ${got} test(s) and is NOT in the record — a new test file must be recorded in the same commit`;
133
+ drifted.push(line); structural.push(line);
123
134
  } else if (got !== want) {
124
135
  drifted.push(`${f}: ran ${got}, record says ${want}`);
125
136
  }
@@ -134,15 +145,30 @@ for (const f of files) {
134
145
  // exists to exercise — the guard's own test caught it.
135
146
  for (const f of Object.keys(expected)) {
136
147
  if (!existsSync(path.join(pkgRoot, f))) {
137
- drifted.push(`${f}: in the record but no such file exists — deleted, or renamed and its coverage lost`);
148
+ const line = `${f}: in the record but no such file exists — deleted, or renamed and its coverage lost`;
149
+ drifted.push(line); structural.push(line);
138
150
  } else if (files.includes(f) === false && GLOB === "test/*.test.mjs") {
139
- drifted.push(`${f}: exists but did not run — the glob no longer selects it`);
151
+ const line = `${f}: exists but did not run — the glob no longer selects it`;
152
+ drifted.push(line); structural.push(line);
140
153
  }
141
154
  }
142
155
  // An unattributed pass would make every per-file number meaningless.
143
156
  const unattributed = actual.get("(unattributed)") ?? 0;
144
157
  if (unattributed) drifted.push(`${unattributed} test(s) passed with no file attribution — the per-file record cannot be verified against this run`);
145
158
 
159
+ // On a red suite: report the structural half (never the counts, which a failing test legitimately
160
+ // changes) and exit on the failures.
161
+ if (failed) {
162
+ if (structural.length) {
163
+ console.error(`[check-test-count] AND, not masked by those failures — ${structural.length} structural drift(s) in the record:`);
164
+ for (const d of structural) console.error(` ${d}`);
165
+ console.error(` Record them with \`node scripts/check-test-count.mjs --write\` once the suite is green.`);
166
+ } else {
167
+ console.error(`[check-test-count] no structural record drift besides the failures above (per-file counts are not judged over a red suite)`);
168
+ }
169
+ process.exit(1);
170
+ }
171
+
146
172
  if (!drifted.length) {
147
173
  console.log(
148
174
  `[check-test-count] ${total} test(s) across ${files.length} file(s), each matching its recorded count — ` +
@@ -60,6 +60,7 @@ import {
60
60
  type TransportKind,
61
61
  } from "./transports/index.js";
62
62
  import { readAllTransportMarkers, markerHoldsLiveProcess } from "./tools/registry.js";
63
+ import { decideTokenMapRefresh } from "./store.js";
63
64
  import { queueWriteSchema } from "./tools/queue-write.js";
64
65
  import { treeProvenance } from "./tools/tree-provenance.js";
65
66
  import { detectSpread } from "./server-spread.js";
@@ -67,7 +68,7 @@ import { answeringServerIdentity } from "./tools/registry.js";
67
68
  import { HerdrTransport, herdrKeyName, interpretHerdrReply, submitModeOf, SUBMIT_MODE_ENV_VAR } from "./transports/herdr.js";
68
69
  import { tickVerdict } from "./tools/tick.js";
69
70
  import { ensureHerdrTail, herdrTailState, newContext, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
70
- import { HERDR, TICK_READS_AS, TICK_STORED_AS, type TickState } from "./transports/types.js";
71
+ import { HERDR, TMUX_PUSH, TICK_READS_AS, TICK_STORED_AS, ownPaneTarget, type TickState } from "./transports/types.js";
71
72
 
72
73
  export type ProbeResult = {
73
74
  id: string;
@@ -431,6 +432,42 @@ const PROBES: Probe[] = [
431
432
  // artefact except the one that calls it. ⟨q-9e0072b3⟩ was filed because the tree carried this
432
433
  // change while the registry did not, and nothing in either could say so — the version label was
433
434
  // identical on both sides of a behaviour difference.
435
+ // ⟨q-54adf191⟩ The daemon read tokens.json ONCE at startup and had no watcher, so a token minted
436
+ // while it ran was refused with a 401 that looks exactly like a broken daemon. A version label
437
+ // cannot answer "does this daemon pick up a new token" — only calling the decision can.
438
+ //
439
+ // ⛔ THE PROBE DRIVES THE REAL RULE WITH A SCRIPTED FILESYSTEM, and touches no tokens.json: the
440
+ // decision is one pure function shared with the live path, so this cannot pass while the daemon
441
+ // behaves differently. The NEGATIVES are the property: a refusal must keep the previous map.
442
+ id: "token-map-reloads-on-change",
443
+ since: "0.26.31",
444
+ run: () => {
445
+ const map = (n: number) => new Map(Array.from({ length: n }, (_, i) => [`tk${i}`, `a${i}`] as const));
446
+ const state = { map: map(1), stamp: "s1", checkedAt: 0 };
447
+ const io = (stamp: string | null, read: () => Map<string, string> | null) => ({ stamp: () => stamp, read });
448
+ const opts = { force: true, now: 1 };
449
+
450
+ const changed = decideTokenMapRefresh(state, io("s2", () => map(2)), opts);
451
+ const unchanged = decideTokenMapRefresh(state, io("s1", () => map(9)), opts);
452
+ const malformed = decideTokenMapRefresh(state, io("s2", () => { throw new Error("not valid JSON"); }), opts);
453
+ const vanished = decideTokenMapRefresh(state, io(null, () => null), opts);
454
+ const limited = decideTokenMapRefresh({ ...state, checkedAt: 1000 }, io("s2", () => map(2)), { now: 1001 });
455
+
456
+ const picksUp = changed.result.changed === true && changed.result.count === 2 && changed.next.map?.size === 2;
457
+ const quiet = unchanged.result.changed === false && unchanged.result.reason === "unchanged";
458
+ const keepsOnBadFile =
459
+ malformed.result.changed === false && malformed.result.reason === "malformed" && malformed.next.map === state.map && malformed.next.stamp === "s1";
460
+ const keepsOnGone = vanished.result.reason === "vanished" && vanished.next.map === state.map;
461
+ const rateLimited = limited.result.reason === "rate-limited" && limited.next.map === state.map;
462
+
463
+ const present = picksUp && quiet && keepsOnBadFile && keepsOnGone && rateLimited;
464
+ return {
465
+ present,
466
+ evidence: `changed -> reloaded ${changed.result.count} agents=${picksUp} · same stamp -> no read=${quiet} · KEEPS THE OLD MAP: malformed=${keepsOnBadFile} (stamp not advanced), vanished=${keepsOnGone} · rate-limited=${rateLimited} -> present=${present}`,
467
+ };
468
+ },
469
+ },
470
+ {
434
471
  id: "transport-source-is-machine-scoped",
435
472
  since: "0.26.28",
436
473
  run: () => {
@@ -623,6 +660,54 @@ const PROBES: Probe[] = [
623
660
  return { present, evidence: `module ${me.serverModule ?? "UNRESOLVED"} · dead stamp -> unknown: ${staleUnknown} · split + unstamped seat -> ${split?.state ?? "n/a"} -> present=${present}` };
624
661
  },
625
662
  },
663
+ {
664
+ // ⟨q-caa2959e⟩ (0.26.30) — the merge-window live control's APPLICABILITY is own landings in the
665
+ // horizon, never its own window count. A quiet fleet (record commits, no landing) is NOT
666
+ // APPLICABLE and says so with both counts; a landing whose PR announced no window is a problem,
667
+ // which is the violation the control exists to catch and the one a count-based rule would skip
668
+ // green. Asked of the running function, so a server on the pre-fix build answers false.
669
+ id: "merge-window-applies-on-own-landings",
670
+ since: "0.26.30",
671
+ run: () => {
672
+ const T = Date.parse("2026-09-20T12:00:00Z");
673
+ const landing = { sha: "a".repeat(40), at: T, author: "seat", subject: "docs(done): close it (#900)", mergeOf: 900 };
674
+ const plain = { sha: "b".repeat(40), at: T, author: "seat", subject: "docs(board): claim a row", mergeOf: null };
675
+ const opts = { now: T + 60_000, floorMs: T - 60_000 };
676
+ const unannounced = mergeWindowLivePopulation({ logText: "", writes: [landing], ...opts });
677
+ const quiet = mergeWindowLivePopulation({ logText: "", writes: [plain], ...opts });
678
+ const windowsOnly = mergeWindowLivePopulation({ logText: `{"ts":${T},"from":"qa","to":"#k","text":"QA GATE: PASS #900\nMERGE WINDOW: #900 — hold queue/board writes"}`, writes: [], ...opts });
679
+ const present =
680
+ unannounced.applicable === true && unannounced.missingWindow.length === 1 &&
681
+ quiet.applicable === false && /landed nothing in the horizon/.test(quiet.reason) && /0 own landing\(s\)/.test(quiet.reason) &&
682
+ windowsOnly.applicable === false;
683
+ return {
684
+ present,
685
+ evidence:
686
+ `landing with no window -> applicable=${unannounced.applicable} missingWindow=${unannounced.missingWindow.length} · ` +
687
+ `record commit, no landing -> applicable=${quiet.applicable} (names both counts: ${/0 own landing\(s\)/.test(quiet.reason)}) · ` +
688
+ `windows but no landing -> applicable=${windowsOnly.applicable} -> present=${present}`,
689
+ };
690
+ },
691
+ },
692
+ {
693
+ // ⟨q-f995c3c7⟩ — a seat re-claiming its OWN pane is not an intruder. The same-pane exception read
694
+ // process.env.TMUX_PANE only, which a herdr seat never has, so no herdr seat could match its own
695
+ // marker and every restart was refused (8 occurrences by 2026-09-23, David's own seat among them).
696
+ // Probed through the function the guard calls, so a server on the pre-fix build answers false.
697
+ id: "own-pane-reclaim-accepted",
698
+ since: "0.26.31",
699
+ run: () => {
700
+ const pane = "wA3:p4";
701
+ const mine = ownPaneTarget(HERDR, { HERDR_PANE_ID: pane } as NodeJS.ProcessEnv);
702
+ const tmuxSeat = ownPaneTarget(TMUX_PUSH, { TMUX_PANE: "%7" } as NodeJS.ProcessEnv);
703
+ const noHerdrPane = ownPaneTarget(HERDR, { TMUX_PANE: "%7" } as NodeJS.ProcessEnv);
704
+ const present = mine === pane && tmuxSeat === "%7" && noHerdrPane === undefined;
705
+ return {
706
+ present,
707
+ evidence: `herdr seat's own pane -> ${mine ?? "undefined"} · tmux seat -> ${tmuxSeat ?? "undefined"} · herdr with only TMUX_PANE -> ${noHerdrPane ?? "undefined"} (the pre-fix read) -> present=${present}`,
708
+ };
709
+ },
710
+ },
626
711
  {
627
712
  id: "record-authority",
628
713
  since: "0.24.0",
@@ -806,7 +891,7 @@ export function probeCapabilities(context: { module: string; versionLabel: strin
806
891
 
807
892
  import { resolveServerIdentity } from "./server-identity.js";
808
893
  import { seatBuildOf, installedFrom, psReader, type SeatBuild } from "./tools/seat-build.js";
809
- import { isInFlightStatus, gateClaimsIn } from "./tools/stall.js";
894
+ import { isInFlightStatus, gateClaimsIn, mergeWindowLivePopulation } from "./tools/stall.js";
810
895
  import { parseWorkDoc, queueItemsOf, stampQueueIds } from "@davidbalzan/groundwork-seam";
811
896
 
812
897
  export const capabilitiesSchema = {} as const;
package/src/server.ts CHANGED
@@ -18,7 +18,10 @@ import { capabilitiesSchema, capabilitiesTool } from "./capabilities.js";
18
18
  import {
19
19
  ensureDirs,
20
20
  getTokenMap,
21
+ TOKENS_FILE,
21
22
  reloadTokenMapSync,
23
+ refreshTokenMapIfChanged,
24
+ type TokenMapRefresh,
22
25
  sessionFile,
23
26
  listSessionFiles,
24
27
  readJson,
@@ -306,7 +309,10 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
306
309
  leakLine +
307
310
  ` A live incumbent can only be superseded with ITS OWN TOKEN now (mint one: scripts/coord-token.mjs add ${claimed}, then SIGHUP the bus) — ` +
308
311
  `\`force:true\` binds a SECOND session to one identity rather than stopping the first, which is the defect this refusal exists to prevent, not a step past it. ` +
309
- `If you ARE '${claimed}' restarting, re-join from its own tmux pane${leaks.length ? " once nothing else holds it" : ""}. ` +
312
+ `If you ARE '${claimed}' restarting, re-join from its own pane${leaks.length ? " once nothing else holds it" : ""} — ` +
313
+ `⟨q-f995c3c7⟩ ANY SERVER RESTART REACHES THIS, including ones no operator sees: \`/model\` restarts this MCP server, ` +
314
+ `and so do a reconnect, a config reload and a crash-restart. The seat is then a NEW process meeting its own leftover ` +
315
+ `record, which is why this fired on seats nobody had touched. ` +
310
316
  `If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
311
317
  );
312
318
  }
@@ -1076,15 +1082,39 @@ async function startHttp(port: number): Promise<void> {
1076
1082
  return transport;
1077
1083
  }
1078
1084
 
1085
+ // ⟨q-54adf191⟩ ONE LINE PER RELOAD, AND ONE PER REFUSAL. A reload nobody can see is
1086
+ // indistinguishable from the bug: the operator reads a 401 and suspects the daemon.
1087
+ function reportTokenRefresh(r: TokenMapRefresh): void {
1088
+ if (r.changed) {
1089
+ console.error(`[agent-coord-mcp] token map reloaded (${r.count} agents) — ${TOKENS_FILE} ${r.reason}`);
1090
+ return;
1091
+ }
1092
+ if (r.refused) {
1093
+ console.error(
1094
+ `[agent-coord-mcp] token map NOT reloaded: ${r.refused} — keeping the previous map (${r.count} agents)`,
1095
+ );
1096
+ }
1097
+ }
1098
+
1079
1099
  // Reverse-lookup: extract bearer from header, map → bound agent. Returns
1080
1100
  // undefined if no map is configured (advisory mode); throws-like return of
1081
1101
  // null if the bearer doesn't match any known agent (caller responds 401).
1082
1102
  function resolveBoundAgent(authHeader: string | undefined): { ok: boolean; agent?: string } {
1083
1103
  if (!authHeader || !authHeader.startsWith("Bearer ")) return { ok: false };
1084
1104
  const bearer = authHeader.slice("Bearer ".length);
1105
+ // ⟨q-54adf191⟩ Pick up a tokens.json that changed while this daemon ran. Rate-limited, so the
1106
+ // steady state is one stat; the file is read only when its stamp moved. Every outcome that is
1107
+ // not "nothing happened" is SAID — a silent reload is the defect this row exists for.
1108
+ reportTokenRefresh(refreshTokenMapIfChanged());
1085
1109
  const tokenMap = getTokenMap();
1086
1110
  if (tokenMap) {
1087
- const agent = tokenMap.get(bearer);
1111
+ let agent = tokenMap.get(bearer);
1112
+ if (!agent) {
1113
+ // An unknown bearer is exactly the symptom of a token minted since the last check, so it
1114
+ // forces one check past the rate limit. It cannot grant anything the file does not hold.
1115
+ reportTokenRefresh(refreshTokenMapIfChanged({ force: true }));
1116
+ agent = getTokenMap()?.get(bearer);
1117
+ }
1088
1118
  // ⛔ THE ONLY PLACE A PRE-BOUND SEAT CAN GET A TAIL. The `startTailOnBind` sites in the tool
1089
1119
  // path all sit behind `bound === undefined` — the TOFU claim. Here identity arrives already
1090
1120
  // decided by the bearer, that branch is unreachable, and before this line NO tail ever
package/src/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { promises as fs, existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { statSync, promises as fs, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
@@ -199,6 +199,10 @@ export function getTokenMap(): Map<string, string> | null {
199
199
  if (!tokenMapInitialized) {
200
200
  tokenMapCache = readTokenMapSync();
201
201
  tokenMapInitialized = true;
202
+ // Stamp the file we just read, or the first change check would report a map that never
203
+ // changed as newly "appeared" and re-read a file nobody touched.
204
+ tokenMapStamp = stampOf();
205
+ tokenMapCheckedAt = Date.now();
202
206
  }
203
207
  return tokenMapCache;
204
208
  }
@@ -209,9 +213,134 @@ export function getTokenMap(): Map<string, string> | null {
209
213
  export function reloadTokenMapSync(): Map<string, string> | null {
210
214
  tokenMapCache = readTokenMapSync();
211
215
  tokenMapInitialized = true;
216
+ // Keep the change-detection stamp in step, or the next stat would read the same file again.
217
+ tokenMapStamp = stampOf();
218
+ tokenMapCheckedAt = Date.now();
212
219
  return tokenMapCache;
213
220
  }
214
221
 
222
+ // ⟨q-54adf191⟩ RELOAD WHEN THE FILE CHANGES, AND SAY WHAT HAPPENED.
223
+ //
224
+ // The daemon read tokens.json once at startup and had no watcher, so a token minted while it ran
225
+ // was refused with a 401 that looks exactly like a broken daemon — every file on disk correct. The
226
+ // operator had to remember SIGHUP. This weakens nothing: the file is the authority either way, and
227
+ // a reload cannot grant a bearer the file does not contain.
228
+ //
229
+ // THE TRIGGER IS A STAMP CHECK ON THE AUTH PATH, NOT A WATCHER AND NOT A TIMER.
230
+ // · a watcher (fs.watch) is per-platform and silently unreliable on network and virtualised
231
+ // filesystems — exactly where a shared bus dir often lives — and it would hold the event loop
232
+ // of an otherwise idle daemon;
233
+ // · a poll timer wakes a process that may serve nothing for hours, and still misses a change
234
+ // that lands between ticks;
235
+ // · a stat on a request the daemon is already serving costs one syscall, is rate-limited, and
236
+ // reads the file ONLY when the stamp moved. An unknown bearer forces one extra check, so a
237
+ // newly minted token authenticates on its first call rather than on the next tick.
238
+ // SIGHUP stays as the explicit path and still forces a read.
239
+ const TOKEN_RELOAD_MIN_MS = (() => {
240
+ const n = Number(process.env.AGENT_COORD_TOKEN_RELOAD_MS);
241
+ return Number.isFinite(n) && n >= 0 ? n : 1000;
242
+ })();
243
+
244
+ /** What a refresh did. `changed` means the in-process map was REPLACED. */
245
+ export type TokenMapRefresh = {
246
+ changed: boolean;
247
+ reason: "unbound" | "rate-limited" | "unchanged" | "changed" | "appeared" | "malformed" | "vanished";
248
+ count: number | null;
249
+ /** Present only when the reload was REFUSED; the previous map still serves. */
250
+ refused?: string;
251
+ };
252
+
253
+ // The file identity we compare against: mtime, size AND inode.
254
+ //
255
+ // ⛔ THE INODE IS LOAD-BEARING, NOT BELT-AND-BRACES. `coord-token` rotates by writing a temp file and
256
+ // renaming it into place, so the replacement is a DIFFERENT INODE that can carry the SAME mtime and
257
+ // the SAME size (a rotated token is the same length as the one it replaces). Without the inode this
258
+ // stamp reads "unchanged" and the daemon keeps serving the REVOKED bearer until someone remembers
259
+ // SIGHUP — the exact defect ⟨q-54adf191⟩ exists to end. Pinned by "a rotation that keeps mtime AND
260
+ // size" below, which goes red the moment the inode leaves this line.
261
+ //
262
+ // ⚠ STATED LIMIT, and the one case this stamp CANNOT see: a rewrite IN PLACE, at equal length, with
263
+ // mtime forced back (`utimes`). Same inode, same size, same mtime is indistinguishable from no write
264
+ // at all, so a bearer revoked that way keeps working until SIGHUP. Measured, and asserted below as a
265
+ // known limit rather than left to be discovered.
266
+ //
267
+ // ⚠⚠ WHY WE ARE SAFE TODAY IS ANOTHER TOOL'S IMPLEMENTATION DETAIL, NOT A PROPERTY OF THIS ONE:
268
+ // `coord-token` never writes this file in place — every write is temp+rename, which moves the inode.
269
+ // The day anything else writes `tokens.json` in place (an editor with a truncating save, a config
270
+ // manager, a hand-rolled script), this comment is the only warning that the reload will not see it.
271
+ // A content hash would close it, at the cost of reading the whole file on every check instead of one
272
+ // stat; that trade was not taken here and is the thing to revisit if that day comes.
273
+ let tokenMapStamp: string | null = null;
274
+ let tokenMapCheckedAt = 0;
275
+
276
+ function stampOf(): string | null {
277
+ try {
278
+ const st = statSync(TOKENS_FILE);
279
+ return `${st.mtimeMs}:${st.size}:${st.ino}`;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ /** The state a refresh decides against: what is loaded, the file identity it came from, when it was last checked. */
286
+ export type TokenMapState = { map: Map<string, string> | null; stamp: string | null; checkedAt: number };
287
+ /** The filesystem, as the decision sees it. Injected so the rules can be exercised without a disk. */
288
+ export type TokenMapIo = { stamp: () => string | null; read: () => Map<string, string> | null };
289
+
290
+ /**
291
+ * THE WHOLE RULE, IN ONE PURE FUNCTION. The live path and the capability probe both call this, so
292
+ * there is no second implementation of "did it change, and may the new map replace the old one".
293
+ */
294
+ export function decideTokenMapRefresh(
295
+ state: TokenMapState,
296
+ io: TokenMapIo,
297
+ opts: { force?: boolean; now?: number } = {},
298
+ ): { next: TokenMapState; result: TokenMapRefresh } {
299
+ const now = opts.now ?? Date.now();
300
+ const keep = (result: TokenMapRefresh, checkedAt = state.checkedAt) => ({ next: { ...state, checkedAt }, result });
301
+ // A daemon with no tokens.json is in advisory mode: there is no map to keep fresh, and a file
302
+ // APPEARING must not silently promote it into bound mode mid-flight.
303
+ if (state.map === null) return keep({ changed: false, reason: "unbound", count: null });
304
+ if (!opts.force && now - state.checkedAt < TOKEN_RELOAD_MIN_MS) {
305
+ return keep({ changed: false, reason: "rate-limited", count: state.map.size });
306
+ }
307
+ const size = state.map.size;
308
+ const stamp = io.stamp();
309
+ if (stamp === null) {
310
+ // ⛔ A VANISHED FILE IS A REFUSAL, NEVER A DOWNGRADE. Dropping to advisory mode because the file
311
+ // is missing would turn `rm tokens.json` into "every bearer is now accepted".
312
+ return keep({ changed: false, reason: "vanished", count: size, refused: `${TOKENS_FILE} is gone` }, now);
313
+ }
314
+ if (stamp === state.stamp) return keep({ changed: false, reason: "unchanged", count: size }, now);
315
+ try {
316
+ const next = io.read();
317
+ if (next === null) {
318
+ return keep({ changed: false, reason: "vanished", count: size, refused: `${TOKENS_FILE} is gone` }, now);
319
+ }
320
+ return {
321
+ next: { map: next, stamp, checkedAt: now },
322
+ result: { changed: true, reason: state.stamp === null ? "appeared" : "changed", count: next.size },
323
+ };
324
+ } catch (e) {
325
+ // The stamp is deliberately NOT advanced: a half-written file that is fixed in place, with no
326
+ // further change to mtime or size, must still be picked up on the next check.
327
+ return keep({ changed: false, reason: "malformed", count: size, refused: (e as Error).message }, now);
328
+ }
329
+ }
330
+
331
+ export function refreshTokenMapIfChanged(opts: { force?: boolean; now?: number } = {}): TokenMapRefresh {
332
+ if (!tokenMapInitialized) getTokenMap();
333
+ const { next, result } = decideTokenMapRefresh(
334
+ { map: tokenMapCache, stamp: tokenMapStamp, checkedAt: tokenMapCheckedAt },
335
+ { stamp: stampOf, read: readTokenMapSync },
336
+ opts,
337
+ );
338
+ tokenMapCache = next.map;
339
+ tokenMapStamp = next.stamp;
340
+ tokenMapCheckedAt = next.checkedAt;
341
+ return result;
342
+ }
343
+
215
344
  // Atomically rotate the token entry for an agent rename (used by
216
345
  // rename_agent so the same bearer continues to authenticate the renamed
217
346
  // identity). No-op if the file is absent or the old id isn't in the map.
@@ -1,5 +1,5 @@
1
1
  import { detachAgentTool } from "./transport.js";
2
- import { isLocallyProbeable, isRemoteTmuxKind, targetOf, HERDR, HerdrTransport, activeTransport } from "../transports/index.js";
2
+ import { isLocallyProbeable, isRemoteTmuxKind, ownPaneTarget, targetOf, HERDR, HerdrTransport, activeTransport } from "../transports/index.js";
3
3
  import { readAway, secondCoordinatorRefusal } from "./away.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { existsSync, openSync, statSync, watch } from "node:fs";
@@ -655,13 +655,14 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
655
655
  reasons.push(
656
656
  `live ${marker.transport} transport (pid ${marker.pid}${targetOf(marker) ? `, pane ${targetOf(marker)}` : ""})`,
657
657
  );
658
- if (
659
- isLocallyProbeable(marker.transport) &&
660
- targetOf(marker) &&
661
- process.env.TMUX_PANE &&
662
- targetOf(marker) === process.env.TMUX_PANE
663
- ) {
658
+ // ⟨q-f995c3c7⟩ THE SAME SEAT RESTARTING INTO ITS OWN PANE, for herdr as well as tmux. This read
659
+ // `process.env.TMUX_PANE` alone, which a herdr seat never has, so the exception could not fire
660
+ // for the fleet that runs on herdr: every seat was refused by its own leftover marker (6 of 6 on
661
+ // 2026-09-23). The pane comes from THIS process's environment, never from the caller's argument.
662
+ const ownPane = ownPaneTarget(marker.transport);
663
+ if (targetOf(marker) && ownPane && targetOf(marker) === ownPane) {
664
664
  samePane = true;
665
+ reasons.push(`the marker names THIS process's own pane (${ownPane})`);
665
666
  }
666
667
  }
667
668
 
@@ -512,6 +512,57 @@ export function mergeWindowChecks(input: { logText: string; writes: RecordWrite[
512
512
  return { hits: withAudience(hits), windows: out, unmeasurable };
513
513
  }
514
514
 
515
+ /**
516
+ * ⟨q-caa2959e⟩ — WHETHER THE MERGE-WINDOW CONVENTION CAN BE JUDGED ON THIS BUS RIGHT NOW,
517
+ * decided from OWN LANDINGS IN THE HORIZON and never from the window count.
518
+ *
519
+ * The live check used to assert `windows.length > 0`. A stand-down left the fleet quiet: 0 window
520
+ * announcements inside the horizon while record commits remained, so a control pinned to a live
521
+ * baseline failed for everyone — it expired when the fleet went quiet, not when anything broke.
522
+ *
523
+ * ⛔ AND APPLICABILITY IS NEVER READ OFF THE PREDICATE'S OWN COUNT. Deciding it from `windows.length`
524
+ * is the by-mention trap ⟨q-552b9912⟩ taught us, inverted: a fleet that landed PRs and announced NO
525
+ * window — precisely the violation this check exists to catch — would skip itself green. The
526
+ * population is what the fleet DID (its landings); the windows are what it is judged ON.
527
+ *
528
+ * Applicable when the horizon holds at least one landing of this fleet's (a squash carrying the
529
+ * forge's `(#N)` marker). Then every one of them MUST carry a window announcement, and a missing one
530
+ * is a problem. With no landing there is nothing to judge: NOT APPLICABLE, named with both counts.
531
+ */
532
+ export function mergeWindowLivePopulation(input: { logText: string; writes: RecordWrite[] | null; now: number; floorMs?: number; horizonMs?: number }): {
533
+ applicable: boolean;
534
+ reason: string;
535
+ population: string;
536
+ landings: RecordWrite[];
537
+ missingWindow: { sha: string; pr: number }[];
538
+ windows: ReturnType<typeof mergeWindowChecks>["windows"];
539
+ hits: ReturnType<typeof mergeWindowChecks>["hits"];
540
+ } {
541
+ const floor = input.floorMs ?? MERGE_WINDOW_ADOPTED_MS;
542
+ const horizon = input.horizonMs ?? CONVENTION_WINDOW_MS;
543
+ const r = mergeWindowChecks(input);
544
+ const since = new Date(input.now - horizon).toISOString();
545
+ const head = `horizon ${since} → ${new Date(input.now).toISOString()}, floor ${new Date(floor).toISOString()}`;
546
+ if (input.writes === null) {
547
+ const reason = `NOT APPLICABLE: record-document writes could not be read from the shared branch — ${head}. UNMEASURED is not the same as kept.`;
548
+ return { applicable: false, reason, population: reason, landings: [], missingWindow: [], windows: r.windows, hits: r.hits };
549
+ }
550
+ const inHorizon = input.writes.filter((c) => Number.isFinite(c.at) && c.at >= input.now - horizon);
551
+ const landings = inHorizon.filter((c) => c.mergeOf !== null);
552
+ const population = `${head}: ${inHorizon.length} record commit(s), ${landings.length} own landing(s), ${r.windows.length} window(s), ${r.hits.length} hit(s)`;
553
+ if (landings.length === 0) {
554
+ return {
555
+ applicable: false,
556
+ reason: `NOT APPLICABLE: this fleet landed nothing in the horizon, so there is no window to require — ${population}. (A quiet fleet, not a broken convention; the window count is never what decides this.)`,
557
+ population, landings, missingWindow: [], windows: r.windows, hits: r.hits,
558
+ };
559
+ }
560
+ const missingWindow = landings
561
+ .filter((c) => !r.windows.some((w) => w.pr === c.mergeOf))
562
+ .map((c) => ({ sha: c.sha, pr: c.mergeOf as number }));
563
+ return { applicable: true, reason: `APPLICABLE — ${population}`, population, landings, missingWindow, windows: r.windows, hits: r.hits };
564
+ }
565
+
515
566
  export function conventionChecks(input: {
516
567
  recentMerges: RecentMerge[] | null | undefined;
517
568
  /** PRs the caller knows merged express. Nothing populates this yet — see isDocsOnlyMerge. */
@@ -76,6 +76,21 @@ export function isLocallyProbeable(transport: string | undefined): boolean {
76
76
  return transport === TMUX_PUSH;
77
77
  }
78
78
 
79
+ /**
80
+ * ⟨q-f995c3c7⟩ THE PANE THIS PROCESS IS RUNNING IN, read from ITS OWN ENVIRONMENT — never from a
81
+ * caller's argument, which is what would make it spoofable.
82
+ *
83
+ * The first-claim guard's same-pane exception asked `process.env.TMUX_PANE` only, and a herdr seat
84
+ * has no TMUX_PANE: it has HERDR_PANE_ID. So a herdr seat restarting into its OWN pane could never
85
+ * satisfy the exception, and every one of them was refused against its own leftover marker. Measured
86
+ * 2026-09-23: 6 of 6 seats, cleared only by moving the markers aside by hand.
87
+ */
88
+ export function ownPaneTarget(transport: string | undefined, env: NodeJS.ProcessEnv = process.env): string | undefined {
89
+ if (transport === HERDR) return env.HERDR_PANE_ID || undefined;
90
+ if (isLocallyProbeable(transport)) return env.TMUX_PANE || undefined;
91
+ return undefined;
92
+ }
93
+
79
94
  /**
80
95
  * Is liveness for this marker decided by the registry heartbeat rather than by a
81
96
  * local pid? True only for the remote kind, whose pid is 0 on a foreign host.
@@ -137,6 +152,19 @@ export type TransportMarker = {
137
152
  * unasked question as full capability is the defect this field removes.
138
153
  */
139
154
  rooms?: boolean;
155
+ /**
156
+ * ⟨q-ec020f6a⟩(A) herdr ONLY: the `agent_session.value` herdr itself reported for this
157
+ * marker's pane at ATTACH TIME (`herdr pane get <target>` — populated from the pane's own
158
+ * live process, e.g. a Claude Code resume-session-id; never asserted by the attaching
159
+ * caller). This is what makes the pane a checkable identity rather than a trusted claim:
160
+ * `resolveBoundAgent`'s pane path re-reads the pane's CURRENT `agent_session.value` on
161
+ * every request and refuses if it no longer matches what was recorded here — the same
162
+ * pane_id with a DIFFERENT live session behind it (a restart, a reused pane, a swap) is
163
+ * exactly the class ⟨q-e439e4ad⟩/⟨q-8c23e5a3⟩ named. Absent means unknown (herdr had no
164
+ * session for that pane at attach time, or this marker predates the field) — never treated
165
+ * as a match.
166
+ */
167
+ herdrAgentSession?: string;
140
168
  };
141
169
 
142
170
  /**
@@ -284,4 +312,12 @@ export interface Transport {
284
312
  * that can PUBLISH state to its external observer exposes it here.
285
313
  */
286
314
  publishTick?(marker: TransportMarker, state: TickState, opts?: { message?: string; source?: string }): Promise<{ ok: boolean; error?: string }>;
315
+
316
+ /**
317
+ * ⟨q-ec020f6a⟩(A) — OPTIONAL, and its absence is the honest answer for tmux (a pane's live
318
+ * "session" concept is a herdr feature; tmux has no equivalent to re-check). Declaring the
319
+ * method optional is what keeps a pane-identity caller from inventing a match for a
320
+ * transport that has no way of asking one. See `HerdrTransport.verifyPaneSession`.
321
+ */
322
+ verifyPaneSession?(target: string, expectedSession: string | undefined): boolean | null;
287
323
  }