agent-coord-mcp 0.26.27 → 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.
@@ -54,17 +54,33 @@ export const pushCursorPath = (root, safeId) => path.join(root, "cursors", `${sa
54
54
  export const readCursorPath = (root, safeId) => path.join(root, "cursors", `${safeId}.json`);
55
55
 
56
56
  /**
57
- * SEEDED FROM THE READ CURSOR ON FIRST RUN, once.
57
+ * ⛔⛔ NOT SEEDED FROM THE READ CURSOR — THE TWO ARE IN DIFFERENT UNITS ⟨q-c36a078f⟩.
58
58
  *
59
- * Without this every existing pusher wakes with an empty push cursor and
60
- * re-pushes its entire history into the pane — turning a data-loss fix into a
61
- * flood. The seed is a COPY, not a link: after it, the two advance
62
- * independently.
59
+ * This used to COPY the read cursor on first run, to stop a new pusher replaying its whole
60
+ * history into the pane. But the READ cursor counts MESSAGES and the PUSH cursor is a BYTE
61
+ * offset that `newMessagesIn` seeks to. So the copy wrote `roomOffsets: { groundwork: 327 }` —
62
+ * "327 messages read" — into a field meaning "byte 327 of an 801 KB file", and the next tail
63
+ * start queued ~783 KB to be typed into the pane one message at a time.
64
+ *
65
+ * Measured on this bus 2026-09-18, before any restart could fire it: three kit seats at 327
66
+ * against 801 KB, two seats of another fleet on the same bus at 1294 against 4.2 MB, one at 8
67
+ * against 24 KB. The reciprocal shape was live too — six seats whose room offset sat PAST the
68
+ * file's end, where new posts are never pushed at all. One field, two units, both directions.
69
+ *
70
+ * ⭐ AND THE REPLAY GUARD IS NOT THIS FUNCTION'S JOB ANY MORE. ⟨q-cdb5b007⟩(a) put it where it
71
+ * belongs: the tail seeds its high-water mark at EOF when it starts (`ctx.seedEof`), in bytes,
72
+ * per source, so nothing predating the tail is typed. Seeding EOF here as well would be a SECOND
73
+ * mechanism for one job — the duplication ⟨q-ec020f6a⟩ exists to remove — and it would make the
74
+ * tail's own control untestable by pre-empting it.
75
+ *
76
+ * So the seed is EMPTY: this function no longer guesses a position in a unit it cannot know. An
77
+ * empty cursor means "nothing has been pushed yet", which is true, and the tail decides where to
78
+ * begin. Anything genuinely unpushed stays reachable via `read_messages` either way.
63
79
  */
64
80
  export function readPushCursor(root, safeId, write = writePushCursor) {
65
81
  const own = readCursorFile(pushCursorPath(root, safeId));
66
82
  if (own !== null) return own;
67
- const seed = readCursorFile(readCursorPath(root, safeId)) ?? {};
83
+ const seed = {};
68
84
  write(root, safeId, seed);
69
85
  return seed;
70
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.26.27",
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 = { duty: r.hits.filter((h) => h.audience === "duty"), "board-owner": r.hits.filter((h) => h.audience !== "duty") };
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
+ }
@@ -52,6 +52,7 @@ 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,
@@ -420,6 +421,46 @@ const PROBES: Probe[] = [
420
421
  };
421
422
  },
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
+ },
423
464
  {
424
465
  // ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — the external tick, probed by CALLING the code that
425
466
  // decides what a reading MEANS (every probe here is synchronous, so the async read is
@@ -566,8 +607,9 @@ export type TransportCapability = {
566
607
  agrees: boolean;
567
608
  /** What was called and what came back. */
568
609
  evidence: string;
569
- /** Where `configured` came from — config file or env. ⟨q-ec020f6a⟩: no built-in default. */
570
- configuredSource: "config" | "env";
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";
571
613
  /**
572
614
  * MIXED FLEET, MADE LOUD (3.4). Agents whose marker names a transport other
573
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
- const bindAddr = process.env.AGENT_COORD_BIND ?? "127.0.0.1";
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
- const isLoopbackBind =
950
- bindAddr === "127.0.0.1" || bindAddr === "localhost" || bindAddr === "::1";
951
- if (!isLoopbackBind) {
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 ${bindAddr} without per-agent tokens: a ` +
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 ${bindAddr}: bearer tokens would ` +
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
- const http = createServer(async (req: IncomingMessage, res: ServerResponse) => {
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
- http.listen(port, bindAddr, () => {
1156
- const mode = bound ? `pre-bound (${getTokenMap()?.size ?? 0} agents)` : "TOFU";
1157
- console.error(`[agent-coord-mcp] http listening on ${bindAddr}:${port} — identity ${mode}`);
1158
- if (bindAddr !== "127.0.0.1" && bindAddr !== "localhost") {
1159
- console.error(
1160
- `[agent-coord-mcp] WARNING: bound to ${bindAddr} without TLS. Front with a TLS reverse proxy ` +
1161
- `(or restrict to a private network e.g. Tailscale/WireGuard) before exposing publicly.`,
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) => {
@@ -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
@@ -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 === "stale-row" || kind === "routed-without-row" || kind === "unverdicted-merge" || kind === "merge-window-write" || kind === "lane-left-population" ? "board-owner" : "duty";
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"]) {
@@ -1668,7 +1668,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1668
1668
  // count makes EVERY push cursor on a non-trivial inbox read as past EOF — and because this
1669
1669
  // check is `fixable`, the "clamp" then rewinds it to the count. ⟨q-f018dd51⟩: on 2026-09-17 a
1670
1670
  // `doctor {fix:true}` did exactly that to 11 of 13 seats across two fleets in one call —
1671
- // cass-poc-coordinator went from byte 3,413,506 to byte 992, i.e. to the START of its inbox.
1671
+ // one coordinator seat went from byte 3,413,506 to byte 992, i.e. to the START of its inbox.
1672
1672
  // Nothing replayed only because the running tails held an in-process high-water mark; the
1673
1673
  // damage was LATENT and would have fired on the next restart, when that mark is gone.
1674
1674
  const boundOf = async (file: string) => (kind === "push" ? await byteSizeOf(file) : (await countFor(file)).parsed);
@@ -1,16 +1,21 @@
1
1
  /**
2
- * WHICH TRANSPORT THIS FLEET IS CONFIGURED TO USE (Phase 5.4 Task 3.1–3.2).
2
+ * WHICH TRANSPORT A SEAT ON THIS MACHINE IS CONFIGURED TO USE (Phase 5.4 Task 3.1–3.2).
3
3
  *
4
- * Whole-fleet, read ONCE at startup, per David 2026-09-11 — no per-seat
5
- * branching in `send_command` or `attach`. Memoised for that reason and not for
6
- * speed: a value that can be re-read mid-process is a value that can change
7
- * mid-process, and then two calls in one session disagree about what the fleet
8
- * is doing.
4
+ * ⟨q-d404a6f6⟩ THIS IS MACHINE-SCOPED, NOT FLEET-WIDE — the file lives at
5
+ * `$AGENT_COORD_DIR/config.json`, a path local to ONE box. A fleet now spans
6
+ * machines: `tmux-push-remote` exists precisely for a seat on another host, and
7
+ * that seat never reads THIS machine's config.json — it has (or lacks) its own.
8
+ * Calling this "the fleet's statement" was false and load-bearing: it read as
9
+ * an argument against ANY compiled default, when the argument that actually
10
+ * holds (below) is narrower — against a default picked HERE being assumed
11
+ * correct on a DIFFERENT machine. Per-process (read ONCE at startup, no
12
+ * per-seat branching in `send_command` or `attach` for the seats ON THIS BOX)
13
+ * is still exactly right; "whole-fleet" was the wrong word for it.
9
14
  *
10
15
  * ⛔ AN UNKNOWN VALUE REFUSES AT STARTUP. It does not fall back to anything.
11
16
  *
12
17
  * The reason is not tidiness. A SILENT FALLBACK AND A CORRECT DEFAULT PRODUCE
13
- * IDENTICAL EVIDENCE: both give you a fleet on some transport with nothing in any
18
+ * IDENTICAL EVIDENCE: both give you a seat on some transport with nothing in any
14
19
  * log, so a typo in the config reads exactly like a deliberate default, and the
15
20
  * person who typed `heardr` spends the afternoon asking why their transport
16
21
  * change did nothing. Refusing is louder than the bug it prevents.
@@ -20,7 +25,7 @@
20
25
  * removing the fallback would have meant every unconfigured session refused
21
26
  * with "unknown transport 'tmux-push'" — a config-file bug wearing a runtime
22
27
  * bug's clothes. Picking a new implicit default (herdr, say) would be correct
23
- * on THIS fleet today and wrong on the next machine that doesn't run herdr —
28
+ * on THIS machine today and wrong on the next one that doesn't run herdr —
24
29
  * the exact silent-guess failure ⟨q-e439e4ad⟩ spent a day fixing, moved one
25
30
  * layer up. So: zero configuration REFUSES, naming the kinds that exist.
26
31
  */
@@ -29,26 +34,27 @@ import path from "node:path";
29
34
  import { ROOT } from "../store.js";
30
35
  import { TRANSPORT_KINDS, type TransportKind } from "./types.js";
31
36
 
32
- /** `$AGENT_COORD_DIR/config.json`, the fleet-wide file. */
37
+ /** `$AGENT_COORD_DIR/config.json` — MACHINE-scoped, not fleet-wide: see the file header. */
33
38
  export const TRANSPORT_CONFIG_FILE = path.join(ROOT, "config.json");
34
39
  export const TRANSPORT_ENV_VAR = "AGENT_COORD_TRANSPORT";
35
40
 
36
41
  /**
37
42
  * PRECEDENCE, documented here because 3.2 asks for a decision and not a
38
- * preference: **config file > env > default**.
43
+ * preference: **machine config file > env**. (⟨q-ec020f6a⟩: no default follows
44
+ * either — see the file header.)
39
45
  *
40
- * The file wins because it is the FLEET's statement and is reviewable — it sits
41
- * on disk where every seat reads the same bytes, and a wrong value can be
42
- * corrected in one place. An env var is per-process: it is the right tool for
43
- * one seat to deviate deliberately (a test, a bisect), and the wrong tool for
44
- * stating what the fleet does, because nothing can see it from outside that
45
- * process. So the narrower, less visible source loses to the broader one, and
46
- * `source` is reported so a surprising answer can be traced to its origin
47
- * rather than guessed at.
46
+ * The file wins because it is this MACHINE's statement and is reviewable — it
47
+ * sits on disk where every seat ON THIS BOX reads the same bytes, and a wrong
48
+ * value can be corrected in one place. An env var is per-process: it is the
49
+ * right tool for one seat to deviate deliberately (a test, a bisect), and the
50
+ * wrong tool for stating what this machine's seats do by default, because
51
+ * nothing can see it from outside that process. So the narrower, less visible
52
+ * source loses to the broader one, and `source` is reported so a surprising
53
+ * answer can be traced to its origin rather than guessed at.
48
54
  */
49
55
  export type ConfiguredTransport = {
50
56
  kind: TransportKind;
51
- source: "config" | "env";
57
+ source: "machine-config" | "env";
52
58
  /** Where the value came from, for an error message a human can act on. */
53
59
  origin: string;
54
60
  };
@@ -73,12 +79,38 @@ function asKind(value: unknown, origin: string): TransportKind {
73
79
  let cached: ConfiguredTransport | undefined;
74
80
 
75
81
  /**
76
- * Resolve the fleet's transport. Throws on an unknown value — call it once at
82
+ * ⭐ ⟨q-9e0072b3⟩ THE PRECEDENCE DECISION, PULLED OUT SO IT CAN BE CALLED WITHOUT TOUCHING
83
+ * DISK OR `process.env`. `configuredTransport()` below is the only caller that supplies real
84
+ * values read from THIS machine; anything else — a test, or a capability probe that must prove
85
+ * a property of the CODE rather than a fact about one machine's config.json — can hand this
86
+ * CONSTRUCTED values instead. That is what makes it possible to probe "does the machine-config/
87
+ * env vocabulary exist and resolve correctly" without the probe itself becoming an undeclared
88
+ * read of a mutable, machine-local baseline (`check-baseline-declared` correctly caught the
89
+ * first version of that probe calling `configuredTransport()` directly for exactly this reason).
90
+ */
91
+ export function resolveConfiguredTransport(input: { fileHasTransport: boolean; fileTransport: unknown; env: string | undefined }): ConfiguredTransport {
92
+ if (input.fileHasTransport) {
93
+ return { kind: asKind(input.fileTransport, `${TRANSPORT_CONFIG_FILE} ("transport")`), source: "machine-config", origin: TRANSPORT_CONFIG_FILE };
94
+ }
95
+ if (input.env !== undefined && input.env !== "") {
96
+ return { kind: asKind(input.env, `$${TRANSPORT_ENV_VAR}`), source: "env", origin: `$${TRANSPORT_ENV_VAR}` };
97
+ }
98
+ throw new Error(
99
+ `[agent-coord-mcp] no transport configured — set $${TRANSPORT_ENV_VAR} or ${TRANSPORT_CONFIG_FILE} ` +
100
+ `("transport") to one of: ${TRANSPORT_KINDS.join(", ")}. ` +
101
+ `REFUSING rather than picking one: a default that is right for this machine today is wrong for the ` +
102
+ `next one that doesn't have it, and the two failures leave identical evidence.`,
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Resolve THIS MACHINE's configured transport. Throws on an unknown value — call it once at
77
108
  * startup so the refusal lands before any agent attaches.
78
109
  */
79
110
  export function configuredTransport(): ConfiguredTransport {
80
111
  if (cached) return cached;
81
112
 
113
+ let fileTransport: unknown;
82
114
  if (existsSync(TRANSPORT_CONFIG_FILE)) {
83
115
  let parsed: unknown;
84
116
  try {
@@ -90,28 +122,14 @@ export function configuredTransport(): ConfiguredTransport {
90
122
  throw new Error(
91
123
  `[agent-coord-mcp] ${TRANSPORT_CONFIG_FILE} is unreadable (${(e as Error).message}). ` +
92
124
  `REFUSING rather than treating it as absent: a file that exists and cannot be read is not the ` +
93
- `same as no file, and defaulting here would hide a stated intent behind a working fleet.`,
125
+ `same as no file, and defaulting here would hide a stated intent behind a seat that looked healthy.`,
94
126
  );
95
127
  }
96
- const raw = (parsed as { transport?: unknown } | null)?.transport;
97
- if (raw !== undefined) {
98
- cached = { kind: asKind(raw, `${TRANSPORT_CONFIG_FILE} ("transport")`), source: "config", origin: TRANSPORT_CONFIG_FILE };
99
- return cached;
100
- }
101
- }
102
-
103
- const env = process.env[TRANSPORT_ENV_VAR];
104
- if (env !== undefined && env !== "") {
105
- cached = { kind: asKind(env, `$${TRANSPORT_ENV_VAR}`), source: "env", origin: `$${TRANSPORT_ENV_VAR}` };
106
- return cached;
128
+ fileTransport = (parsed as { transport?: unknown } | null)?.transport;
107
129
  }
108
130
 
109
- throw new Error(
110
- `[agent-coord-mcp] no transport configured — set $${TRANSPORT_ENV_VAR} or ${TRANSPORT_CONFIG_FILE} ` +
111
- `("transport") to one of: ${TRANSPORT_KINDS.join(", ")}. ` +
112
- `REFUSING rather than picking one: a default that is right for this fleet today is wrong for the ` +
113
- `next machine that doesn't have it, and the two failures leave identical evidence.`,
114
- );
131
+ cached = resolveConfiguredTransport({ fileHasTransport: fileTransport !== undefined, fileTransport, env: process.env[TRANSPORT_ENV_VAR] });
132
+ return cached;
115
133
  }
116
134
 
117
135
  /**