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.
- package/dist/capabilities.js +81 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/server.js +27 -3
- package/dist/server.js.map +1 -1
- package/dist/store.js +108 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/registry.js +8 -5
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/stall.js +42 -0
- package/dist/tools/stall.js.map +1 -1
- package/dist/transports/types.js +16 -0
- package/dist/transports/types.js.map +1 -1
- package/package.json +1 -1
- package/scripts/check-test-count.mjs +33 -7
- package/src/capabilities.ts +87 -2
- package/src/server.ts +32 -2
- package/src/store.ts +130 -1
- package/src/tools/registry.ts +8 -7
- package/src/tools/stall.ts +51 -0
- package/src/transports/types.ts +36 -0
package/dist/transports/types.js
CHANGED
|
@@ -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;
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 — ` +
|
package/src/capabilities.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
package/src/tools/registry.ts
CHANGED
|
@@ -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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
package/src/tools/stall.ts
CHANGED
|
@@ -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. */
|
package/src/transports/types.ts
CHANGED
|
@@ -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
|
}
|