agent-coord-mcp 0.26.26 → 0.26.28
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/README.md +18 -12
- package/dist/bind.js +40 -0
- package/dist/bind.js.map +1 -0
- package/dist/capabilities.js +72 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/server.js +48 -15
- package/dist/server.js.map +1 -1
- package/dist/tools/herdr-tail.js +7 -1
- package/dist/tools/herdr-tail.js.map +1 -1
- package/dist/tools/stall.js +29 -1
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +68 -10
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/config.js +42 -25
- package/dist/transports/config.js.map +1 -1
- package/dist/transports/herdr.js +2 -2
- package/dist/transports/herdr.js.map +1 -1
- package/hooks/push-cursor.mjs +22 -6
- package/hooks/submit.mjs +12 -1
- package/package.json +2 -1
- package/scripts/coord-stall-clock.mjs +26 -2
- package/src/bind.ts +40 -0
- package/src/capabilities.ts +77 -3
- package/src/server.ts +57 -18
- package/src/tools/herdr-tail.ts +18 -1
- package/src/tools/stall.ts +48 -3
- package/src/tools/transport.ts +62 -10
- package/src/transports/config.ts +56 -38
- package/src/transports/herdr.ts +9 -3
package/hooks/submit.mjs
CHANGED
|
@@ -323,7 +323,18 @@ export function readyProfileStartupLine(who) {
|
|
|
323
323
|
// How many lines may sit below the input box's lower rule. Claude Code draws a footer there
|
|
324
324
|
// (mode line, hints); a dialog, picker or autocomplete menu draws many more.
|
|
325
325
|
export const READY_BOX_MAX_BELOW = 4;
|
|
326
|
-
|
|
326
|
+
// ⟨q-c8032c5f⟩ herdr's `pane read --source visible` can draw a SHORT SINGLE-TOKEN pane label
|
|
327
|
+
// into the middle of an otherwise-unbroken rule (measured on a live seat: 201 U+2500 dashes
|
|
328
|
+
// with " qa2 " spliced in near the end) — herdr's own frame chrome overlaid onto the exact
|
|
329
|
+
// row Claude Code drew as a plain rule, not something Claude Code's own render ever does.
|
|
330
|
+
// A pure exact-match (`/^─{8,}$/`) read that as "not a rule at all" and held every push to
|
|
331
|
+
// that seat: the line WAS the rule, annotated, and the annotation is what broke the match —
|
|
332
|
+
// not the pane, not the transport, not the seat. Tolerate exactly one such label (a single
|
|
333
|
+
// run of non-space, non-dash characters, flanked by spaces, flanked by dashes on both sides)
|
|
334
|
+
// and nothing looser: multi-word content, a label with no dashes on one side, or two labels
|
|
335
|
+
// still fail — those are genuinely not a rule, and loosening further would just move the
|
|
336
|
+
// false-refusal into a false-accept instead of fixing it.
|
|
337
|
+
const RULE_RE = /^─{8,}(?: [^\s─]+ )?─*$/;
|
|
327
338
|
const BOX_LINE_RE = /^❯(?:\s|$)/;
|
|
328
339
|
const MAX_DRAFT_LINES = 20;
|
|
329
340
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.28",
|
|
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": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"anthropic",
|
|
51
51
|
"ipc",
|
|
52
52
|
"chat",
|
|
53
|
+
"herdr",
|
|
53
54
|
"tmux"
|
|
54
55
|
],
|
|
55
56
|
"license": "MIT",
|
|
@@ -76,6 +76,13 @@ const duty = argv.duty ? String(argv.duty) : null;
|
|
|
76
76
|
// and never the lane's agent. From --board-owner, else the board's own Rooms
|
|
77
77
|
// table (`boardOwnerOf`), else the duty officer with a line saying so.
|
|
78
78
|
const boardOwnerFlag = argv["board-owner"] ? String(argv["board-owner"]) : null;
|
|
79
|
+
// ⟨q-11257590⟩ — coordinator absence has NO duty owner and NO board owner: the
|
|
80
|
+
// coordinator seat itself is the thing missing. David's ruling: "if we have a
|
|
81
|
+
// comms issue that just needs to be surfaced to me then i tackle it" — so this
|
|
82
|
+
// audience gets its own recipient, defaulting to the literal id `david` (the
|
|
83
|
+
// registered human seat on this bus) rather than folding into board-owner,
|
|
84
|
+
// which would address the DM to the very seat that is absent.
|
|
85
|
+
const davidFlag = String(argv.david ?? "david");
|
|
79
86
|
const from = String(argv.from ?? "coord-stall-clock");
|
|
80
87
|
const stallMinutes = argv["stall-minutes"] ? Number(argv["stall-minutes"]) : undefined;
|
|
81
88
|
|
|
@@ -114,10 +121,15 @@ try {
|
|
|
114
121
|
case "lane-left-population": return `- ${h.agentId}: "${h.stream}" LEFT the scored population since the last run with no closing state — its row is gone; close it through a state (✅/⏸/⛔), never by deletion`;
|
|
115
122
|
case "unread-delete-claim": return `- ${h.agentId}: closing line for ${h.pr === null ? "a merge" : `#${h.pr}`} ${h.minutes}m ago asserts a branch deletion it did not show it read — say \`remote ref read: 0 refs at <ts>\` or \`delete requested\``;
|
|
116
123
|
case "merge-window-write": return `- ${String(h.sha).slice(0, 7)} (${h.author}) wrote a record document ${h.secondsIntoWindow}s into MERGE WINDOW #${h.pr} — hold queue/board/DONE writes between the window's open and close`;
|
|
124
|
+
case "coordinator-absent": return `- COORDINATOR ABSENT: '${h.agentId}' — ${h.why}`;
|
|
117
125
|
default: return `- ${JSON.stringify(h)}`;
|
|
118
126
|
}
|
|
119
127
|
};
|
|
120
|
-
const byAudience = {
|
|
128
|
+
const byAudience = {
|
|
129
|
+
duty: r.hits.filter((h) => h.audience === "duty"),
|
|
130
|
+
"board-owner": r.hits.filter((h) => h.audience === "board-owner"),
|
|
131
|
+
david: r.hits.filter((h) => h.audience === "david"),
|
|
132
|
+
};
|
|
121
133
|
let boardOwner = boardOwnerFlag;
|
|
122
134
|
if (!boardOwner) {
|
|
123
135
|
const bp = path.join(repo, "docs/WORKSTREAMS.md");
|
|
@@ -144,12 +156,24 @@ try {
|
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
158
|
|
|
159
|
+
if (byAudience.david.length) {
|
|
160
|
+
// No fallback and no "nobody" case: David's ruling was explicit — this
|
|
161
|
+
// audience exists precisely because nothing else in the fleet can act on
|
|
162
|
+
// it, so it always addresses `davidFlag` (default `david`), never the
|
|
163
|
+
// board owner (who may BE the absent coordinator) and never the duty officer.
|
|
164
|
+
await sendMessageTool({
|
|
165
|
+
from, to: davidFlag,
|
|
166
|
+
record: { type: "blocker", payload: { summary: `stall_check — coordinator absence: ${byAudience.david.map((h) => h.agentId).join(", ")}` } },
|
|
167
|
+
text: `BLOCKER: stall_check — ${byAudience.david.length} coordinator-absence finding(s). Nothing in the fleet can act on this; it is surfaced to you per your ruling ("coord should never be absent in reality... if we have a comms issue that just needs to be surfaced to me then i tackle it").\n${byAudience.david.map(line).join("\n")}${trailer}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
147
171
|
// Null result to stdout every run: the log is the record a human reads, and a
|
|
148
172
|
// trigger that only speaks when it fires cannot be told from a broken one.
|
|
149
173
|
const um = r.unmeasurable?.length ? `, ${r.unmeasurable.length} unmeasurable` : "";
|
|
150
174
|
console.log(
|
|
151
175
|
r.hits.length
|
|
152
|
-
? `[stall-clock] HIT ${r.hits.length}/${r.checked}${um} — duty:${byAudience.duty.length}${duty ? ` → ${duty}` : " (NO DUTY OFFICER SET, no DM)"} · board-owner:${byAudience["board-owner"].length}${byAudience["board-owner"].length ? ` → ${boardOwner ?? (duty ? `${duty} (fallback)` : "nobody")}` : ""}`
|
|
176
|
+
? `[stall-clock] HIT ${r.hits.length}/${r.checked}${um} — duty:${byAudience.duty.length}${duty ? ` → ${duty}` : " (NO DUTY OFFICER SET, no DM)"} · board-owner:${byAudience["board-owner"].length}${byAudience["board-owner"].length ? ` → ${boardOwner ?? (duty ? `${duty} (fallback)` : "nobody")}` : ""} · david:${byAudience.david.length}${byAudience.david.length ? ` → ${davidFlag}` : ""}`
|
|
153
177
|
: `[stall-clock] MISS 0/${r.checked}${um} — no DM, run recorded`,
|
|
154
178
|
);
|
|
155
179
|
} catch (e) {
|
package/src/bind.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⟨q-79ee2086⟩ Where the bus listens.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ ITS OWN MODULE BECAUSE `server.ts` RUNS ON IMPORT. `main()` is invoked at the bottom of that
|
|
5
|
+
* file, so importing it to reach a helper starts a real server and never returns — a test that did
|
|
6
|
+
* so would hang rather than fail, which is worse. These two predicates decide who can reach the bus,
|
|
7
|
+
* so they must be reachable by a test without standing one up.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `AGENT_COORD_BIND` as a comma-separated LIST.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ A LIST RATHER THAN `0.0.0.0`, and the difference is the whole point. `0.0.0.0` binds every
|
|
14
|
+
* interface this host has now AND every one it gains later — a VPN coming up silently widens the
|
|
15
|
+
* listener with nothing in any log. A list states exactly which doors are open, is reviewable on
|
|
16
|
+
* disk, and cannot grow behind the operator's back.
|
|
17
|
+
*
|
|
18
|
+
* Empty or unset is `127.0.0.1`, the safe default. Duplicates collapse — the same address twice
|
|
19
|
+
* would otherwise be a self-inflicted `EADDRINUSE` at startup, read as a real conflict. Order of
|
|
20
|
+
* first appearance is preserved so the startup log matches what the operator wrote.
|
|
21
|
+
*/
|
|
22
|
+
export function parseBindList(raw: string | undefined): string[] {
|
|
23
|
+
const parts = String(raw ?? "")
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((s) => s.trim())
|
|
26
|
+
.filter((s) => s.length > 0);
|
|
27
|
+
if (parts.length === 0) return ["127.0.0.1"];
|
|
28
|
+
return [...new Set(parts)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Loopback exactly — the set that needs no per-agent token and no out-of-band-security assertion,
|
|
33
|
+
* because nothing off this host can reach it.
|
|
34
|
+
*
|
|
35
|
+
* ⚠ AN EXACT MATCH, never a prefix or a regex. `127.0.0.1` is loopback; `127.0.0.10` and
|
|
36
|
+
* `localhost.attacker.net` are not, and a gate satisfiable by a lookalike string is not a gate.
|
|
37
|
+
*/
|
|
38
|
+
export function isLoopbackAddr(addr: string): boolean {
|
|
39
|
+
return addr === "127.0.0.1" || addr === "localhost" || addr === "::1";
|
|
40
|
+
}
|
package/src/capabilities.ts
CHANGED
|
@@ -48,10 +48,11 @@ import { injectLine } from "../hooks/tier.mjs";
|
|
|
48
48
|
import { findControlByte } from "../hooks/control-bytes.mjs";
|
|
49
49
|
// ⟨q-15d763dc⟩ The ready-box guard's profile, as this process read it from its own env at start.
|
|
50
50
|
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
51
|
-
import { readyProfile } from "../hooks/submit.mjs";
|
|
51
|
+
import { readyProfile, readReadyBox } from "../hooks/submit.mjs";
|
|
52
52
|
import { subscriptionHealth } from "./tools/events.js";
|
|
53
53
|
import {
|
|
54
54
|
configuredTransport,
|
|
55
|
+
resolveConfiguredTransport,
|
|
55
56
|
runningTransport,
|
|
56
57
|
isTmuxKind,
|
|
57
58
|
targetOf,
|
|
@@ -388,6 +389,78 @@ const PROBES: Probe[] = [
|
|
|
388
389
|
}
|
|
389
390
|
},
|
|
390
391
|
},
|
|
392
|
+
{
|
|
393
|
+
// ⛔⛔ ⟨q-c8032c5f⟩ — the ready-box guard HELD EVERY PUSH to a live seat (ticks 191,
|
|
394
|
+
// delivered 0, held 98) reporting "the ❯ line has no ─ rule directly above it" against a pane
|
|
395
|
+
// that WAS ready. herdr splices the pane's own label into the frame, so the rule Claude Code
|
|
396
|
+
// drew as plain dashes arrives as `──… qa2 ─`. `RULE_RE` demanded an unbroken dash line and
|
|
397
|
+
// read the annotated one as not-a-rule: a false negative in the matcher, reported as a fact
|
|
398
|
+
// about the pane. The seat stayed queued and functional for PULL and simply never WOKE.
|
|
399
|
+
//
|
|
400
|
+
// ⚠ THE NEGATIVES ARE THE PROBE. Accepting the label is one line; accepting it WITHOUT opening
|
|
401
|
+
// a false-accept is the property. A matcher that took any annotated rule would pass a
|
|
402
|
+
// positive-only probe while typing into dialogs and menus — so a multi-word label, a label
|
|
403
|
+
// with no dash after it, and two labels are each asserted REFUSED alongside the accept.
|
|
404
|
+
id: "ready-box-tolerates-pane-label",
|
|
405
|
+
since: "0.26.27",
|
|
406
|
+
run: () => {
|
|
407
|
+
const D = "─".repeat(60);
|
|
408
|
+
const screen = (rule: string) => [`${D} above ${D}`, rule, "❯", D, " ⏵⏵ bypass permissions on"].join("\n");
|
|
409
|
+
const readyOf = (rule: string) => readReadyBox(screen(rule), "claude-code") as { ready: boolean; reason?: string };
|
|
410
|
+
|
|
411
|
+
const labelled = readyOf(`${D} qa2 ${D}`).ready === true; // the live failure, now accepted
|
|
412
|
+
const plain = readyOf(D).ready === true; // control: the un-annotated rule must still work
|
|
413
|
+
const multiWord = readyOf(`${D} two words ${D}`).ready === false;
|
|
414
|
+
const noTrailingDash = readyOf(`${D} qa2 `).ready === false;
|
|
415
|
+
const twoLabels = readyOf(`${D} a ${D} b ${D}`).ready === false;
|
|
416
|
+
|
|
417
|
+
const present = labelled && plain && multiWord && noTrailingDash && twoLabels;
|
|
418
|
+
return {
|
|
419
|
+
present,
|
|
420
|
+
evidence: `herdr-labelled rule accepted=${labelled} · plain rule still accepted=${plain} · REFUSED: multi-word=${multiWord}, no-trailing-dash=${noTrailingDash}, two-labels=${twoLabels} -> present=${present}`,
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
// ⟨q-d404a6f6⟩/⟨q-9e0072b3⟩ — `configuredSource` is a WIRE VALUE, and #384 changed it from
|
|
426
|
+
// "config" to "machine-config" because `config.json` is machine-scoped, not fleet-wide. Anything
|
|
427
|
+
// comparing `configuredSource === "config"` — the console, a gate, a seat checking its own
|
|
428
|
+
// status — reads one answer from a 0.26.27 server and another from this one.
|
|
429
|
+
//
|
|
430
|
+
// ⚠ WHICH IS WHY IT NEEDS A PROBE AND NOT JUST A BUMP: a renamed output is invisible to every
|
|
431
|
+
// artefact except the one that calls it. ⟨q-9e0072b3⟩ was filed because the tree carried this
|
|
432
|
+
// change while the registry did not, and nothing in either could say so — the version label was
|
|
433
|
+
// identical on both sides of a behaviour difference.
|
|
434
|
+
id: "transport-source-is-machine-scoped",
|
|
435
|
+
since: "0.26.28",
|
|
436
|
+
run: () => {
|
|
437
|
+
// ⛔ THIS PROBE MUST PROVE A PROPERTY OF THE CODE, NEVER A FACT ABOUT THIS MACHINE.
|
|
438
|
+
// The first version called `configuredTransport()` directly — which reads THIS seat's
|
|
439
|
+
// real config.json off disk — and `check-baseline-declared` correctly flagged that as an
|
|
440
|
+
// UNDECLARED mutable-baseline read: a probe run by `check-probe-coverage.mjs` in the gate
|
|
441
|
+
// chain has no business depending on whatever transport this one box happens to have
|
|
442
|
+
// configured. It also, as a side effect, called `configuredTransport()` uncaught: that
|
|
443
|
+
// throws by design on an unconfigured seat, and `probeCapabilities`'s outer catch turned
|
|
444
|
+
// the throw into an undiscriminated `present:false`, breaking `capabilities.ok` for any
|
|
445
|
+
// fresh/unregistered seat (test/status-capabilities.test.mjs's unregistered-agent case).
|
|
446
|
+
//
|
|
447
|
+
// ✅ FIXED BOTH WAYS BY CALLING `resolveConfiguredTransport` — the PURE decision
|
|
448
|
+
// `configuredTransport()` delegates to — WITH CONSTRUCTED INPUTS instead: two cases, one
|
|
449
|
+
// for each live source, built here rather than read from anywhere. Nothing outside this
|
|
450
|
+
// process is touched, so there is no baseline to declare, and there is no live-config
|
|
451
|
+
// state that could throw.
|
|
452
|
+
const machineConfig = resolveConfiguredTransport({ fileHasTransport: true, fileTransport: "herdr", env: undefined });
|
|
453
|
+
const env = resolveConfiguredTransport({ fileHasTransport: false, fileTransport: undefined, env: "herdr" });
|
|
454
|
+
const sources = [machineConfig.source, env.source] as const;
|
|
455
|
+
const known = sources.every((s) => s === "machine-config" || s === "env");
|
|
456
|
+
const retired = (sources as readonly string[]).includes("config");
|
|
457
|
+
const present = known && !retired;
|
|
458
|
+
return {
|
|
459
|
+
present,
|
|
460
|
+
evidence: `constructed machine-config case -> source=${JSON.stringify(machineConfig.source)} · constructed env case -> source=${JSON.stringify(env.source)} · in current vocabulary={machine-config,env}=${known} · either is retired "config"=${retired} -> present=${present}`,
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
},
|
|
391
464
|
{
|
|
392
465
|
// ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — the external tick, probed by CALLING the code that
|
|
393
466
|
// decides what a reading MEANS (every probe here is synchronous, so the async read is
|
|
@@ -534,8 +607,9 @@ export type TransportCapability = {
|
|
|
534
607
|
agrees: boolean;
|
|
535
608
|
/** What was called and what came back. */
|
|
536
609
|
evidence: string;
|
|
537
|
-
/** Where `configured` came from — config
|
|
538
|
-
|
|
610
|
+
/** Where `configured` came from — this machine's config.json, or env. ⟨q-d404a6f6⟩: config.json is
|
|
611
|
+
* machine-scoped, not fleet-wide. ⟨q-ec020f6a⟩: no built-in default. */
|
|
612
|
+
configuredSource: "machine-config" | "env";
|
|
539
613
|
/**
|
|
540
614
|
* MIXED FLEET, MADE LOUD (3.4). Agents whose marker names a transport other
|
|
541
615
|
* than the running one. Whole-fleet is the rule; this is the code noticing
|
package/src/server.ts
CHANGED
|
@@ -118,6 +118,7 @@ import {
|
|
|
118
118
|
isPidAlive,
|
|
119
119
|
} from "./tools/index.js";
|
|
120
120
|
import { queueWriteSchema, queueWriteTool } from "./tools/queue-write.js";
|
|
121
|
+
import { parseBindList, isLoopbackAddr } from "./bind.js";
|
|
121
122
|
|
|
122
123
|
function jsonResult(data: unknown) {
|
|
123
124
|
return {
|
|
@@ -939,19 +940,27 @@ async function startHttp(port: number): Promise<void> {
|
|
|
939
940
|
"to pre-bind sessions to identities at connect time.",
|
|
940
941
|
);
|
|
941
942
|
}
|
|
942
|
-
|
|
943
|
+
// ⟨q-79ee2086⟩ A LIST, because one address forces a choice the deployment should not have to
|
|
944
|
+
// make. The bus was reachable on the Tailscale address OR the LAN and never both, so adding a
|
|
945
|
+
// second machine meant MOVING the bind and breaking every local seat's config at the same moment.
|
|
946
|
+
// A single value still parses to a one-element list, so existing deployments are unaffected.
|
|
947
|
+
const bindAddrs = parseBindList(process.env.AGENT_COORD_BIND);
|
|
943
948
|
const sharedExpected = sharedToken ? `Bearer ${sharedToken}` : null;
|
|
944
949
|
|
|
945
950
|
// Fail-closed network gate. A non-loopback bind is a real network listener, so
|
|
946
951
|
// it must have (a) enforced per-agent identity and (b) a secured transport. We
|
|
947
952
|
// refuse rather than warn: a shared/advisory token lets any node impersonate
|
|
948
953
|
// any agent, and plaintext leaks bearer tokens to anyone on the path.
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
954
|
+
//
|
|
955
|
+
// ⛔ EVALUATED OVER THE WHOLE LIST, NOT PER ADDRESS. `127.0.0.1,192.168.1.10` must NOT pass
|
|
956
|
+
// because one entry happens to be loopback — the listener on the second address is exposed
|
|
957
|
+
// whatever the first one is, and a gate that a caller can satisfy by prepending a loopback
|
|
958
|
+
// address is not a gate. The offending address is NAMED so the refusal is actionable.
|
|
959
|
+
const exposed = bindAddrs.filter((a) => !isLoopbackAddr(a));
|
|
960
|
+
if (exposed.length > 0) {
|
|
952
961
|
if (!bound) {
|
|
953
962
|
console.error(
|
|
954
|
-
`[agent-coord-mcp] refusing to bind ${
|
|
963
|
+
`[agent-coord-mcp] refusing to bind ${exposed.join(", ")} without per-agent tokens: a ` +
|
|
955
964
|
`shared/advisory token lets any node impersonate any agent. Create ` +
|
|
956
965
|
`~/agent-coord/tokens.json (per-agent, enforced identity) for network binds.`,
|
|
957
966
|
);
|
|
@@ -959,7 +968,7 @@ async function startHttp(port: number): Promise<void> {
|
|
|
959
968
|
}
|
|
960
969
|
if (process.env.AGENT_COORD_INSECURE !== "1") {
|
|
961
970
|
console.error(
|
|
962
|
-
`[agent-coord-mcp] refusing plaintext bind to ${
|
|
971
|
+
`[agent-coord-mcp] refusing plaintext bind to ${exposed.join(", ")}: bearer tokens would ` +
|
|
963
972
|
`travel in cleartext. Put the bus behind TLS or a private overlay ` +
|
|
964
973
|
`(Tailscale/WireGuard), then set AGENT_COORD_INSECURE=1 to acknowledge the ` +
|
|
965
974
|
`transport is secured out-of-band.`,
|
|
@@ -1092,7 +1101,11 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1092
1101
|
return sharedExpected && authHeader === sharedExpected ? { ok: true } : { ok: false };
|
|
1093
1102
|
}
|
|
1094
1103
|
|
|
1095
|
-
|
|
1104
|
+
// ⛔ ONE HANDLER, N SERVERS. Node binds a single address per `http.Server`, so a list of
|
|
1105
|
+
// addresses is a list of SERVERS — not a loop over `listen`. They share this closure, and with
|
|
1106
|
+
// it the session map and the bound-agent map, so a session created on one address is the same
|
|
1107
|
+
// session on another: the addresses are doors into one bus, not separate buses.
|
|
1108
|
+
const handler = async (req: IncomingMessage, res: ServerResponse) => {
|
|
1096
1109
|
try {
|
|
1097
1110
|
// Unauthenticated liveness probe so reverse proxies / orchestrators can
|
|
1098
1111
|
// health-check without needing a credential.
|
|
@@ -1150,18 +1163,44 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1150
1163
|
res.end("internal error\n");
|
|
1151
1164
|
}
|
|
1152
1165
|
}
|
|
1153
|
-
}
|
|
1166
|
+
};
|
|
1154
1167
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1168
|
+
const mode = bound ? `pre-bound (${getTokenMap()?.size ?? 0} agents)` : "TOFU";
|
|
1169
|
+
await Promise.all(
|
|
1170
|
+
bindAddrs.map(
|
|
1171
|
+
(addr) =>
|
|
1172
|
+
new Promise<void>((resolve) => {
|
|
1173
|
+
const server = createServer(handler);
|
|
1174
|
+
// ⛔ FAIL CLOSED ON A BIND ERROR. A declared address that cannot be bound —
|
|
1175
|
+
// EADDRNOTAVAIL because the interface is not up yet, EADDRINUSE because something
|
|
1176
|
+
// else holds the port — must REFUSE, not leave the daemon serving the subset that
|
|
1177
|
+
// happened to work. A bus up on one of two declared addresses while saying nothing is
|
|
1178
|
+
// the silent-partial-success shape this codebase refuses everywhere else: the seats on
|
|
1179
|
+
// the missing address fail to connect and nothing explains why. launchd's KeepAlive
|
|
1180
|
+
// already covers the honest case (tailscaled has not brought utun0 up yet) by retrying.
|
|
1181
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
1182
|
+
console.error(
|
|
1183
|
+
`[agent-coord-mcp] refusing to start: cannot bind ${addr}:${port} (${err.code ?? err.message}). ` +
|
|
1184
|
+
`AGENT_COORD_BIND declares ${bindAddrs.length} address(es) and every one must bind — ` +
|
|
1185
|
+
`serving only some would leave seats on the others unable to connect, with nothing said.`,
|
|
1186
|
+
);
|
|
1187
|
+
process.exit(1);
|
|
1188
|
+
});
|
|
1189
|
+
server.listen(port, addr, () => {
|
|
1190
|
+
// One line PER ADDRESS: a single "http listening on …" would be read as the whole
|
|
1191
|
+
// picture, and the operator needs to see which doors are actually open.
|
|
1192
|
+
console.error(`[agent-coord-mcp] http listening on ${addr}:${port} — identity ${mode}`);
|
|
1193
|
+
if (!isLoopbackAddr(addr)) {
|
|
1194
|
+
console.error(
|
|
1195
|
+
`[agent-coord-mcp] WARNING: bound to ${addr} without TLS. Front with a TLS reverse proxy ` +
|
|
1196
|
+
`(or restrict to a private network e.g. Tailscale/WireGuard) before exposing publicly.`,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
resolve();
|
|
1200
|
+
});
|
|
1201
|
+
}),
|
|
1202
|
+
),
|
|
1203
|
+
);
|
|
1165
1204
|
}
|
|
1166
1205
|
|
|
1167
1206
|
main().catch((err) => {
|
package/src/tools/herdr-tail.ts
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
import { readFileSync, statSync } from "node:fs";
|
|
41
41
|
import { inboxFile, roomFile, getRooms, transportFile } from "../store.js";
|
|
42
|
-
import { activeTransport, HERDR, type Transport, type TransportMarker } from "../transports/index.js";
|
|
42
|
+
import { activeTransport, HERDR, targetOf, type Transport, type TransportMarker } from "../transports/index.js";
|
|
43
43
|
import { renderForPane, advancePushCursor, readPushCursorFor, type Message } from "./herdr-delivery.js";
|
|
44
44
|
import { newMessagesIn } from "./jsonl-offsets.js";
|
|
45
45
|
|
|
@@ -103,9 +103,19 @@ export async function tailOnce(
|
|
|
103
103
|
ctx?: TailContext;
|
|
104
104
|
markerOf?: (agentId: string) => TransportMarker | null;
|
|
105
105
|
roomsOf?: () => Promise<Record<string, { members?: string[] }>>;
|
|
106
|
+
/**
|
|
107
|
+
* ⟨q-1ce5bc97⟩ step 1 — EVERY push outcome, logged, not just the ones that end up in
|
|
108
|
+
* `held`. Before this, bus-daemon.err.log recorded no push outcomes at all: a
|
|
109
|
+
* `delivered` and a silently-dropped `typed-unconfirmed` looked identical from the
|
|
110
|
+
* log, because nothing wrote either one. Injectable so a test can capture lines
|
|
111
|
+
* without a real stderr; defaults to `console.error` — the same sink every other
|
|
112
|
+
* diagnostic in this process already writes to.
|
|
113
|
+
*/
|
|
114
|
+
log?: (line: string) => void;
|
|
106
115
|
} = {},
|
|
107
116
|
): Promise<TailOutcome> {
|
|
108
117
|
const out: TailOutcome = { delivered: [], held: [] };
|
|
118
|
+
const log = opts.log ?? ((l: string) => console.error(l));
|
|
109
119
|
const t = opts.transport ?? activeTransport();
|
|
110
120
|
if (!t || t.kind !== HERDR) return { ...out, idle: "this server's transport is not herdr — the tail is for a herdr seat's own inbox" };
|
|
111
121
|
const ctx = opts.ctx ?? newContext();
|
|
@@ -155,6 +165,13 @@ export async function tailOnce(
|
|
|
155
165
|
const rendered = await renderForPane(msg as unknown as Message, agentId, where);
|
|
156
166
|
let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
|
|
157
167
|
try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
|
|
168
|
+
// ⟨q-1ce5bc97⟩ step 1(b) — EVERY push outcome, logged, before any branch decides what to do
|
|
169
|
+
// with it. Not just the ones that end up `held`: a `delivered` needs a line too, or the
|
|
170
|
+
// log can never distinguish "nothing was owed" from "something was pushed and it worked".
|
|
171
|
+
log(
|
|
172
|
+
`[herdr-tail] push id=${String(msg.id ?? "?")} target=${targetOf(marker) ?? "?"} outcome=${r.outcome ?? (r.delivered ? "delivered" : "unknown")}` +
|
|
173
|
+
(r.error ? ` reason=${JSON.stringify(r.error)}` : ""),
|
|
174
|
+
);
|
|
158
175
|
if (!r.delivered && r.safeToRetry === true) {
|
|
159
176
|
// HELD: the transport sent NO key (no ready input box, a draft, a dialog, an unreadable pane).
|
|
160
177
|
// Nothing moves and the message is retried next tick, which costs only a screen read. Stop
|
package/src/tools/stall.ts
CHANGED
|
@@ -134,10 +134,20 @@ const haltFile = () => path.join(ROOT, "halt.json");
|
|
|
134
134
|
* board-owner bookkeeping: a row pointing at a landed branch, a routed item
|
|
135
135
|
* with no row, a merge with no verdict comment. Somebody must
|
|
136
136
|
* fix the RECORD; the lane's owner did nothing wrong.
|
|
137
|
+
* david ⟨q-11257590⟩ nobody IN THE FLEET can act on this: the coordinator
|
|
138
|
+
* seat itself is the thing missing, so there is no duty owner and no
|
|
139
|
+
* board-owner left to fix the record. David's own ruling: "coord
|
|
140
|
+
* should never be absent in reality... if we have a comms issue that
|
|
141
|
+
* just needs to be surfaced to me then i tackle it" — this audience
|
|
142
|
+
* exists so a relayer routes it to him and nowhere else.
|
|
137
143
|
*/
|
|
138
|
-
export type HitAudience = "duty" | "board-owner";
|
|
144
|
+
export type HitAudience = "duty" | "board-owner" | "david";
|
|
139
145
|
export const audienceOf = (kind: StallHitBody["kind"]): HitAudience =>
|
|
140
|
-
kind === "
|
|
146
|
+
kind === "coordinator-absent"
|
|
147
|
+
? "david"
|
|
148
|
+
: kind === "stale-row" || kind === "routed-without-row" || kind === "unverdicted-merge" || kind === "merge-window-write" || kind === "lane-left-population"
|
|
149
|
+
? "board-owner"
|
|
150
|
+
: "duty";
|
|
141
151
|
const withAudience = (hits: StallHitBody[]): StallHit[] => hits.map((h) => ({ ...h, audience: audienceOf(h.kind) }) as StallHit);
|
|
142
152
|
export type StallHit = StallHitBody & { audience: HitAudience };
|
|
143
153
|
export type StallHitBody =
|
|
@@ -226,7 +236,19 @@ export type StallHitBody =
|
|
|
226
236
|
* abandoned or genuinely unlanded — so the hit SAYS so, or a true alarm
|
|
227
237
|
* with a false implication teaches the reader to ignore the axis.
|
|
228
238
|
*/
|
|
229
|
-
| { kind: "unproposed-branch"; agentId: string; branch: string; head: string; ahead: number; minutes: number; note: string }
|
|
239
|
+
| { kind: "unproposed-branch"; agentId: string; branch: string; head: string; ahead: number; minutes: number; note: string }
|
|
240
|
+
/**
|
|
241
|
+
* ⟨q-11257590⟩ — David's ruling: coordinator absence is a FAULT to be surfaced,
|
|
242
|
+
* never a state to be silently covered by a stand-in. `agentId` is read from the
|
|
243
|
+
* board's own Rooms table (`boardOwnerOf`), the same name every seat is told at
|
|
244
|
+
* `join` — never hardcoded, so a renamed coordinator seat is still found. Fires
|
|
245
|
+
* when the seat the board itself names has no registry entry at all, or has one
|
|
246
|
+
* but no currently-live transport marker (`loadLiveTransports`, which already
|
|
247
|
+
* verifies pid/pane/remote-heartbeat liveness for every transport kind — this
|
|
248
|
+
* reuses that verdict rather than re-deriving it). A present-and-active
|
|
249
|
+
* coordinator produces NO hit: the negative control this row's acceptance requires.
|
|
250
|
+
*/
|
|
251
|
+
| { kind: "coordinator-absent"; agentId: string; why: string };
|
|
230
252
|
|
|
231
253
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
232
254
|
* ⟨q-6f0a3d81⟩ — THE HANDOVER IS THE THING BEING MEASURED, AND EVERY INSTRUMENT
|
|
@@ -1345,6 +1367,29 @@ export async function stallCheckTool(
|
|
|
1345
1367
|
}));
|
|
1346
1368
|
const now = Date.now();
|
|
1347
1369
|
const hits: StallHitBody[] = [];
|
|
1370
|
+
// ⟨q-11257590⟩ — COORDINATOR ABSENCE IS A FAULT, SURFACED, NEVER COVERED. Read the
|
|
1371
|
+
// seat's own name from the board (`boardOwnerOf`, the Rooms table's "topic owner"
|
|
1372
|
+
// cell — the same identity every seat is told at `join`), not hardcoded, so a
|
|
1373
|
+
// renamed or re-elected coordinator is still found. `reg` and `liveTransports` are
|
|
1374
|
+
// already loaded above for the role rows; this reuses their verdicts rather than
|
|
1375
|
+
// re-deriving liveness. NO board owner named → nothing to check, not a hit: an
|
|
1376
|
+
// unnamed owner is a board-authoring gap, a different row's problem.
|
|
1377
|
+
const coordId = boardOwnerOf(boardText);
|
|
1378
|
+
if (coordId) {
|
|
1379
|
+
if (!reg[coordId]) {
|
|
1380
|
+
hits.push({
|
|
1381
|
+
kind: "coordinator-absent", agentId: coordId,
|
|
1382
|
+
why: `the board names '${coordId}' as coordinator (topic owner) but the bus has no registry entry for it — never joined, or evicted`,
|
|
1383
|
+
});
|
|
1384
|
+
} else if (!liveTransports.has(coordId)) {
|
|
1385
|
+
hits.push({
|
|
1386
|
+
kind: "coordinator-absent", agentId: coordId,
|
|
1387
|
+
why: `'${coordId}' is registered but has no live transport marker right now — registered once, unreachable now`,
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
// Present AND active → no hit. This is the row's required negative control: a
|
|
1391
|
+
// healthy coordinator must not surface, or this becomes a standing false alarm.
|
|
1392
|
+
}
|
|
1348
1393
|
/** The base's tip, for telling an empty claim-time push from a lane with commits. */
|
|
1349
1394
|
let baseTip: string | null = null;
|
|
1350
1395
|
for (const cand of ["origin/main", "origin/master", "main", "master"]) {
|
package/src/tools/transport.ts
CHANGED
|
@@ -1634,6 +1634,16 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1634
1634
|
if (!counts.has(file)) counts.set(file, await scanJsonl(file));
|
|
1635
1635
|
return counts.get(file)!;
|
|
1636
1636
|
};
|
|
1637
|
+
/** A PUSH cursor's bound: bytes on disk, the same figure `advancePushCursor` writes. ⟨q-f018dd51⟩ */
|
|
1638
|
+
const sizes = new Map<string, number>();
|
|
1639
|
+
const byteSizeOf = async (file: string) => {
|
|
1640
|
+
if (!sizes.has(file)) {
|
|
1641
|
+
let n = 0;
|
|
1642
|
+
try { n = (await fsp.stat(file)).size; } catch { n = 0; }
|
|
1643
|
+
sizes.set(file, n);
|
|
1644
|
+
}
|
|
1645
|
+
return sizes.get(file)!;
|
|
1646
|
+
};
|
|
1637
1647
|
|
|
1638
1648
|
// 4. Cursor offsets past end-of-file (would return [] forever).
|
|
1639
1649
|
//
|
|
@@ -1645,20 +1655,46 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1645
1655
|
// agent's inbox in the first place.
|
|
1646
1656
|
{
|
|
1647
1657
|
const broken: string[] = [];
|
|
1658
|
+
const lagging: string[] = [];
|
|
1648
1659
|
for (const fname of await listCursorFiles()) {
|
|
1649
1660
|
const { id, kind } = agentIdFromCursorFilename(fname);
|
|
1650
1661
|
const cursorPath = path.join(CURSOR_DIR, fname);
|
|
1651
1662
|
const cursor = await readJson<Cursor>(cursorPath, {});
|
|
1652
1663
|
const overflow: string[] = [];
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1664
|
+
// ⛔⛔ THE TWO CURSOR KINDS ARE IN DIFFERENT UNITS, AND CONFLATING THEM ARMED A DESTRUCTIVE FIX.
|
|
1665
|
+
// A READ cursor (`<agent>.json`) indexes PARSED ENTRIES — a message count. A PUSH cursor
|
|
1666
|
+
// (`<agent>.push.json`) is a BYTE OFFSET: `advancePushCursor` writes `toOffset ?? statSync(file).size`,
|
|
1667
|
+
// "the byte just past the line that was delivered". Measuring a byte offset against a message
|
|
1668
|
+
// count makes EVERY push cursor on a non-trivial inbox read as past EOF — and because this
|
|
1669
|
+
// check is `fixable`, the "clamp" then rewinds it to the count. ⟨q-f018dd51⟩: on 2026-09-17 a
|
|
1670
|
+
// `doctor {fix:true}` did exactly that to 11 of 13 seats across two fleets in one call —
|
|
1671
|
+
// one coordinator seat went from byte 3,413,506 to byte 992, i.e. to the START of its inbox.
|
|
1672
|
+
// Nothing replayed only because the running tails held an in-process high-water mark; the
|
|
1673
|
+
// damage was LATENT and would have fired on the next restart, when that mark is gone.
|
|
1674
|
+
const boundOf = async (file: string) => (kind === "push" ? await byteSizeOf(file) : (await countFor(file)).parsed);
|
|
1675
|
+
const unit = kind === "push" ? "bytes" : "entries";
|
|
1676
|
+
const inboxMax = await boundOf(inboxFile(id));
|
|
1677
|
+
if ((cursor.inboxOffset ?? 0) > inboxMax) overflow.push(`inboxOffset ${cursor.inboxOffset}>${inboxMax} ${unit}`);
|
|
1678
|
+
const roomMax = await boundOf(ROOM_FILE);
|
|
1679
|
+
if ((cursor.roomOffset ?? 0) > roomMax) overflow.push(`roomOffset ${cursor.roomOffset}>${roomMax} ${unit}`);
|
|
1680
|
+
const statusMax = await boundOf(STATUS_FILE);
|
|
1681
|
+
if ((cursor.statusOffset ?? 0) > statusMax) overflow.push(`statusOffset ${cursor.statusOffset}>${statusMax} ${unit}`);
|
|
1659
1682
|
for (const [chan, off] of Object.entries(cursor.roomOffsets ?? {})) {
|
|
1660
|
-
const max =
|
|
1661
|
-
if (off > max) overflow.push(`roomOffsets[${chan}] ${off}>${max}`);
|
|
1683
|
+
const max = await boundOf(roomFile(chan));
|
|
1684
|
+
if (off > max) overflow.push(`roomOffsets[${chan}] ${off}>${max} ${unit}`);
|
|
1685
|
+
}
|
|
1686
|
+
// ⭐ THE DIRECTION THAT WAS NEVER TESTED, AND THE ONE THAT ACTUALLY BIT. Everything above asks
|
|
1687
|
+
// "is the cursor PAST the end" — an over-run. A seat goes silently deaf from the opposite
|
|
1688
|
+
// shape: its push cursor sits BEHIND a grown inbox, so mail is owed and never typed
|
|
1689
|
+
// (⟨q-cdb5b007⟩). doctor reported "all cursor offsets are within bounds" for seats 10KB
|
|
1690
|
+
// behind, all day, while a worker missed its GO and idled 40 minutes.
|
|
1691
|
+
// ⛔ REPORTED, NEVER "FIXED": advancing a lagging cursor to EOF SKIPS the backlog rather than
|
|
1692
|
+
// delivering it. The remedy is a working tail; a clamp here would destroy the evidence and
|
|
1693
|
+
// the mail. This is why lag is deliberately absent from the `fix` branch below.
|
|
1694
|
+
if (kind === "push") {
|
|
1695
|
+
const size = await byteSizeOf(inboxFile(id));
|
|
1696
|
+
const behind = size - Number(cursor.inboxOffset ?? 0);
|
|
1697
|
+
if (behind > 0) lagging.push(`${id}: ${behind} byte(s) owed (push cursor ${cursor.inboxOffset ?? 0}/${size})`);
|
|
1662
1698
|
}
|
|
1663
1699
|
if (overflow.length) {
|
|
1664
1700
|
broken.push(`${id}${kind === "push" ? " (push)" : ""}: ${overflow.join(", ")}`);
|
|
@@ -1669,7 +1705,9 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1669
1705
|
if ((c.statusOffset ?? 0) > statusMax) c.statusOffset = statusMax;
|
|
1670
1706
|
if (c.roomOffsets) {
|
|
1671
1707
|
for (const chan of Object.keys(c.roomOffsets)) {
|
|
1672
|
-
|
|
1708
|
+
// Same per-kind bound as the detection above — a clamp that used the other unit
|
|
1709
|
+
// would be the very rewind this row exists to stop.
|
|
1710
|
+
const max = kind === "push" ? (sizes.get(roomFile(chan)) ?? 0) : (counts.get(roomFile(chan))?.parsed ?? 0);
|
|
1673
1711
|
if (c.roomOffsets[chan] > max) c.roomOffsets[chan] = max;
|
|
1674
1712
|
}
|
|
1675
1713
|
}
|
|
@@ -1682,10 +1720,24 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1682
1720
|
findings.push({
|
|
1683
1721
|
check: "cursor-past-eof",
|
|
1684
1722
|
level: broken.length ? "error" : "ok",
|
|
1685
|
-
detail: broken.length
|
|
1723
|
+
detail: broken.length
|
|
1724
|
+
? `${broken.length} cursor(s) with an offset past EOF — delivery stalled`
|
|
1725
|
+
: "all cursor offsets are within bounds (push cursors measured in BYTES, read cursors in parsed entries)",
|
|
1686
1726
|
fixable: true,
|
|
1687
1727
|
items: broken.length ? broken : undefined,
|
|
1688
1728
|
});
|
|
1729
|
+
// A SEPARATE FINDING, AND DELIBERATELY `fixable: false`. Lag is the ⟨q-cdb5b007⟩ deafness shape:
|
|
1730
|
+
// mail owed and never typed. It is a symptom of a tail that is not running or not typing, and
|
|
1731
|
+
// the remedy is that tail — advancing the cursor would SKIP the backlog, not deliver it.
|
|
1732
|
+
findings.push({
|
|
1733
|
+
check: "push-cursor-lag",
|
|
1734
|
+
level: lagging.length ? "warn" : "ok",
|
|
1735
|
+
detail: lagging.length
|
|
1736
|
+
? `${lagging.length} seat(s) with mail owed but not yet typed into their pane — a tail that is not running, or running and holding. Check herdrTail: delivered/held tells you which, and they have opposite fixes. NOT auto-repairable: advancing a lagging cursor SKIPS the backlog instead of delivering it.`
|
|
1737
|
+
: "no push cursor is behind its inbox",
|
|
1738
|
+
fixable: false,
|
|
1739
|
+
items: lagging.length ? lagging : undefined,
|
|
1740
|
+
});
|
|
1689
1741
|
}
|
|
1690
1742
|
|
|
1691
1743
|
// 5. Malformed JSONL lines (silently desync offset math between server + hooks).
|