agent-coord-mcp 0.26.23 → 0.26.25

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.
Files changed (63) hide show
  1. package/dist/capabilities.js +180 -5
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/gated-head.js +172 -6
  4. package/dist/gated-head.js.map +1 -1
  5. package/dist/server-spread.js +60 -53
  6. package/dist/server-spread.js.map +1 -1
  7. package/dist/server.js +41 -2
  8. package/dist/server.js.map +1 -1
  9. package/dist/tools/away.js +20 -2
  10. package/dist/tools/away.js.map +1 -1
  11. package/dist/tools/herdr-delivery.js +86 -26
  12. package/dist/tools/herdr-delivery.js.map +1 -1
  13. package/dist/tools/herdr-tail.js +221 -0
  14. package/dist/tools/herdr-tail.js.map +1 -0
  15. package/dist/tools/jsonl-offsets.js +37 -0
  16. package/dist/tools/jsonl-offsets.js.map +1 -0
  17. package/dist/tools/messaging.js +18 -0
  18. package/dist/tools/messaging.js.map +1 -1
  19. package/dist/tools/queue-write.js +4 -1
  20. package/dist/tools/queue-write.js.map +1 -1
  21. package/dist/tools/record-events.js +42 -4
  22. package/dist/tools/record-events.js.map +1 -1
  23. package/dist/tools/records.js +24 -5
  24. package/dist/tools/records.js.map +1 -1
  25. package/dist/tools/registry.js +56 -21
  26. package/dist/tools/registry.js.map +1 -1
  27. package/dist/tools/stall.js +73 -9
  28. package/dist/tools/stall.js.map +1 -1
  29. package/dist/tools/tick.js +77 -0
  30. package/dist/tools/tick.js.map +1 -0
  31. package/dist/tools/transport.js +4 -3
  32. package/dist/tools/transport.js.map +1 -1
  33. package/dist/transports/herdr.js +203 -19
  34. package/dist/transports/herdr.js.map +1 -1
  35. package/dist/transports/index.js +1 -1
  36. package/dist/transports/index.js.map +1 -1
  37. package/dist/transports/types.js +41 -0
  38. package/dist/transports/types.js.map +1 -1
  39. package/hooks/control-bytes.mjs +69 -0
  40. package/hooks/submit.mjs +227 -9
  41. package/hooks/tier.mjs +7 -1
  42. package/hooks/tmux-pusher.mjs +5 -1
  43. package/package.json +1 -1
  44. package/scripts/coord-pusher.mjs +5 -1
  45. package/src/capabilities.ts +183 -4
  46. package/src/gated-head.ts +193 -6
  47. package/src/server-spread.ts +52 -4
  48. package/src/server.ts +38 -1
  49. package/src/tools/away.ts +38 -2
  50. package/src/tools/herdr-delivery.ts +92 -24
  51. package/src/tools/herdr-tail.ts +244 -0
  52. package/src/tools/jsonl-offsets.ts +29 -0
  53. package/src/tools/messaging.ts +20 -0
  54. package/src/tools/queue-write.ts +4 -1
  55. package/src/tools/record-events.ts +46 -4
  56. package/src/tools/records.ts +24 -4
  57. package/src/tools/registry.ts +56 -20
  58. package/src/tools/stall.ts +80 -12
  59. package/src/tools/tick.ts +117 -0
  60. package/src/tools/transport.ts +5 -4
  61. package/src/transports/herdr.ts +251 -19
  62. package/src/transports/index.ts +1 -1
  63. package/src/transports/types.ts +71 -0
package/src/gated-head.ts CHANGED
@@ -207,13 +207,200 @@ export function gatedBy(verdicts: PassVerdict[], head: string, mergedAt: number
207
207
  }
208
208
 
209
209
  /**
210
- * The typed verdict line on a PR page (#313's grammar): `QA GATE — **PASS** @ \`sha\``
211
- * or the merge-time `**GATE: PASS** … @ \`sha\``. Uppercase PASS/FAIL as a whole
212
- * word on a line that opens with the gate marker and carries an `@ sha`. Prose
213
- * that says "passes", "verdict" or "FAILs" does not match — #299's shape, where
214
- * a PASS|FAIL substring scan would have counted an explicit non-verdict review.
210
+ * ⛔ #313's GRAMMAR, KEPT ONLY FOR BACKWARDS COMPATIBILITY — IT MATCHED NOTHING IN
211
+ * THE FIELD. `QA GATE — **PASS** @ \`sha\`` was ruled canonical and then had ZERO
212
+ * ADOPTION: measured against the 9 merges of 2026-09-15, it matched 0 verdict
213
+ * lines while all 9 merges carried one, so `unverdicted-merge` fired 9 times on 9
214
+ * correct merges — 100% false positive, which trains the room to ignore the one
215
+ * instrument protecting the convention.
216
+ *
217
+ * ⭐ THE LESSON IS WHY THIS CONSTANT IS NO LONGER THE PREDICATE: A REGEX OVER PROSE
218
+ * TESTS THE PHRASING, NOT THE CLAIM. The verdicts were never missing; only the
219
+ * agreed wording was. Nobody noticed because every verdict is read BY EYE at gate
220
+ * time and they all read fine — the instrument and the humans disagreed silently
221
+ * for a day.
222
+ *
223
+ * ⚠ SCOPE, CORRECTED: "zero adoption" IS TRUE OF A CONSUMER FLEET AND FALSE OF THIS REPO.
224
+ * Measured: 0 of 9 of that fleet's merges carry this grammar; 4 of 6 kit merges do, verbatim
225
+ * (`#342`, `#340`, `#337`, `#336`). The original write-up scoped a measurement to one
226
+ * consumer and stated it universally.
227
+ *
228
+ * ⛔⛔ AND DO NOT BELIEVE THIS CONSTANT IS WHAT KEEPS THAT KIT GRAMMAR WORKING — I
229
+ * CLAIMED THAT AND IT IS FALSE. `verdictBoundTo` → `verdictClaimsIn` NEVER references
230
+ * `VERDICT_COMMENT`; it survives only in the legacy `verdictShasIn`. Compatibility comes
231
+ * from `DECLARES` accepting the `(?:QA )?GATE\b` opener. ⇒ THE TRAP THAT MAKES THIS
232
+ * WORTH WRITING DOWN: deleting this constant as dead code leaves the kit grammar working,
233
+ * so the experiment CONFIRMS the false mechanism — whoever ran it would conclude they had
234
+ * removed something load-bearing and got away with it. If you are removing it, the thing
235
+ * to check is `DECLARES`, not this.
236
+ *
237
+ * ⛔ ⟨q-3cd5a77d⟩ THE RESULT WORD MUST STAND IN THE CLAIM POSITION — right after the opener, past
238
+ * only separators (`:` `—` `–` `-`, spaces) and an optional `**`. The first version accepted PASS or
239
+ * FAIL ANYWHERE before the `@`, so a ROUTING header read as a verdict:
240
+ * `GATE, prioritised: #359 (⟨q-3605ede3⟩, test-all prints PASS after FAILED) @ 0826519e…`
241
+ * — the word was in the PR's TITLE. Bus history is permanent, so that one DM (11:19:43Z) turned
242
+ * `verdict-names-gater`'s live-population test red on every chain on the machine, and made
243
+ * `stall_check` report #359 as `reported-unscribed` with the COORDINATOR claiming PASS at its head.
244
+ * Measured before the change, old vs new over 126 real lines (the 61-line verdict corpus + every
245
+ * distinct line the old form matched on the live bus and in kit PR comments): they differ on
246
+ * exactly that routing line, and agree — result word and sha — on the other 125.
247
+ */
248
+ export const VERDICT_COMMENT = /^(?:QA GATE|\*\*GATE|GATE)\b[\s:—–-]*(?:\*\*\s*)?(PASS|FAIL)\b[^\n]*?@\s*`?([0-9a-f]{7,40})`?/;
249
+
250
+ /**
251
+ * WHAT MAKES A VERDICT BINDING, expressed as a shape instead of a phrase: a
252
+ * DISPOSITION and a SHA, on ONE LINE, with the disposition ahead of the `@`. The
253
+ * caller then decides bindingness by comparing that sha to the head it cares
254
+ * about — the claim, not the header.
255
+ *
256
+ * Derived from the five spellings actually in the field on 2026-09-15, none of
257
+ * which the constant above matches:
258
+ * `## QA verdict — DONE: PASS \`#970\` @ \`sha\` · mode: full-local · base …`
259
+ * `## QA verdict — DONE: CONTENT PASS \`#964\` @ \`sha\``
260
+ * `## QA verdict — DONE: READINESS RELEASED \`#964\` @ \`sha\``
261
+ * `## QA-2 — FULL PASS (content + readiness) @ \`sha\``
262
+ * `## qa-2 — CONTENT PASS (re-bind) @ \`sha\` · READINESS NOT CLEAR`
263
+ *
264
+ * ⛔ THE HALVES ARE DISTINGUISHED, BECAUSE A CONTENT PASS IS NOT A CLEARANCE. It
265
+ * says so in its own body ("READINESS NOT ESTABLISHED — do not merge yet"), so
266
+ * counting it as a whole verdict would license exactly the merge it forbids. A
267
+ * caller wanting "was this clear to merge" needs a WHOLE verdict, or BOTH halves.
268
+ *
269
+ * ⛔ ORDER MATTERS IN THE CLASSIFIER: `CONTENT PASS` and `READINESS RELEASED` are
270
+ * tested before bare `PASS`, and `READINESS NOT CLEAR`/`NOT ESTABLISHED` must not
271
+ * read as a readiness half — the live `## qa-2 — CONTENT PASS (re-bind) @ sha ·
272
+ * READINESS NOT CLEAR` line carries both phrases and is a CONTENT half only.
273
+ */
274
+ export type VerdictClaim = { result: string; sha: string; half: "whole" | "content" | "readiness" };
275
+ const NEGATED_READINESS = /READINESS\s+(?:NOT|IS\s+NOT)\b/i;
276
+ /**
277
+ * ⛔ THE LINE MUST DECLARE, NOT REMARK — and this anchor is here because removing it
278
+ * BROKE THE AIDE'S #299 CONTROL, which caught it immediately. That control's sharpest
279
+ * fixture is `Looks like a PASS to me — see the run @ \`sha\` for the log; not a gate.`:
280
+ * uppercase PASS, an `@`, and the MERGED HEAD's own sha. Disposition-plus-bound-sha alone
281
+ * counts it, and a chat remark then silences the check for a genuinely unverdicted merge.
282
+ *
283
+ * So a claim is a HEADING (every live verdict on 2026-09-15 is `## …`) or the legacy gate
284
+ * opener. That is still structure rather than phrasing — what it requires is that someone
285
+ * DECLARED a disposition, not that they used a particular sentence — and it is the part of
286
+ * #313's grammar worth keeping: `QA GATE` was the wrong half to demand, `is a declaration`
287
+ * was the right one.
288
+ */
289
+ const DECLARES = /^\s*(?:#{1,6}\s+|\*\*(?:QA )?GATE|(?:QA )?GATE\b)/;
290
+ /** An explicit disclaimer on the same line is dispositive — the author says it is not one. */
291
+ const DISCLAIMED = /\bnot a (?:gate|verdict)\b/i;
292
+
293
+ /**
294
+ * ⛔⛔ A WITHDRAWAL IS NOT THE VERDICT IT WITHDRAWS, AND THE NAIVE READING IS THE DANGEROUS ONE.
295
+ * A consumer fleet's gate seat posted these on one of its PRs; both bound as a clean PASS before this line
296
+ * existed:
297
+ * `## ⛔ VOIDING my PASS-HOLD @ <sha> — that head is two moves dead`
298
+ * `## ⛔ VOIDING my PASS @ <sha>`
299
+ * A line that revokes a verdict names that verdict — so matching the disposition word inside it
300
+ * turns a RETRACTION into a CLEARANCE. That is a FALSE NEGATIVE in a watchdog, which is
301
+ * indistinguishable from a clean run: `unverdicted-merge` would stay silent on a PR merged after
302
+ * its verdict was voided, which is precisely the merge it exists to catch.
303
+ *
304
+ * ⛔⛔ AND POSITION IS THE DISCRIMINATOR, NOT PRESENCE — the first version tested this pattern
305
+ * ANYWHERE on the line and failed in the OPPOSITE direction. A verdict that REPLACES an earlier
306
+ * one routinely says so in its own header, which is the practice that keeps a superseded verdict
307
+ * VISIBLE instead of silently edited:
308
+ * `## QA GATE — **FAIL** @ <sha> — superseding my CONTENT PASS above` ← verbatim, a consumer fleet's PR
309
+ * Discarding that one makes `unverdicted-merge` FIRE ON A PROPERLY GATED MERGE — and it
310
+ * concentrates where gating was MOST active, because a PR whose verdict got corrected is one that
311
+ * received MORE scrutiny. The instrument would go loudest exactly where it is least needed.
312
+ *
313
+ * ⇒ IN A WITHDRAWAL THE VERB PRECEDES THE DISPOSITION; IN A SUPERSEDING VERDICT THE DISPOSITION
314
+ * COMES FIRST. qa-2's shape, and it is measured rather than reasoned: across ALL 124 verdict-shaped
315
+ * lines in both repos (101 of them declaration-shaped), exactly TWO carry a revocation verb, and
316
+ * they split correctly by position — 1 withdrawal, 1 superseding verdict, 99 plain verdicts.
317
+ *
318
+ * ⚠ THE RESIDUAL, STATED WITH ITS DENOMINATOR RATHER THAN LEFT IMPLIED: `PASS @ <sha> —
319
+ * SUPERSEDED, see below` puts the verb after the disposition and would be ACCEPTED. qa-2 raised it
320
+ * as its own invention and flagged it as not field-observed; the corpus agrees — ZERO of 124. It is
321
+ * also partly self-correcting, since whatever supersedes it binds the same sha later.
215
322
  */
216
- export const VERDICT_COMMENT = /^(?:QA GATE|\*\*GATE|GATE)\b[^\n]*?\b(PASS|FAIL)\b(?:\s*\([^)\n]*\))?[^\n]*?@\s*`?([0-9a-f]{7,40})`?/;
323
+ const REVOCATION_VERB = /\b(?:VOID(?:ING|ED)?|WITHDRAW(?:N|ING)?|RETRACT(?:ED|ING)?|SUPERSED(?:ED|ING|ES))\b/i;
324
+ /** True only when the revocation verb comes BEFORE the disposition it names. */
325
+ function isWithdrawal(line: string, dispositionIndex: number): boolean {
326
+ const verb = REVOCATION_VERB.exec(line);
327
+ return verb !== null && verb.index < dispositionIndex;
328
+ }
329
+
330
+ /**
331
+ * ⛔ `PASS-HOLD` IS A DOCUMENTED DO-NOT-MERGE VERDICT (`coord-qa/SKILL.md:71,74`) AND `\bPASS\b`
332
+ * MATCHED INSIDE IT, because `-` is a non-word character. So the one verdict type whose entire
333
+ * meaning is "do not merge" classified as a clearance and BOUND.
334
+ * ⚠ It had not yet fired, and that was LUCK: qa-2's own `#974` PASS-HOLD missed only because the
335
+ * head moved and the sha stopped agreeing. Nothing in the classifier prevented it.
336
+ */
337
+ const HOLD = /\bPASS-HOLD\b/i;
338
+
339
+ /**
340
+ * ⭐ FOUND BY THE CORPUS, NOT BY A GATE — the fourth non-clearance form, and the first one this
341
+ * suite caught itself rather than a reviewer catching it:
342
+ * `## QA — **READINESS REFUSED** @ <sha> · the CONTENT PASS above still stands` (a consumer fleet's PR)
343
+ * ⚠ IT ALREADY BEHAVED CORRECTLY, AND ONLY BY ACCIDENT: `READINESS REFUSED` is not a disposition,
344
+ * and the `CONTENT PASS` it mentions sits AFTER the `@ <sha>`, so no claim matched. Reorder that
345
+ * sentence — `the CONTENT PASS above still stands @ <sha>` — and the refusal would have BOUND as a
346
+ * content half. Naming it makes the behaviour a decision instead of a word-order coincidence.
347
+ */
348
+ const REFUSED = /\bREADINESS\s+REFUSED\b/i;
349
+ /** A disposition ahead of an `@ sha` on the same line. The sha's binding is the caller's test. */
350
+ const CLAIM_LINE = /\b(READINESS\s+RELEASED|CONTENT\s+PASS|FULL\s+PASS|PASS-HOLD|PASS|FAIL)\b[^\n]*?@\s*`?([0-9a-f]{7,40})`?/i;
351
+ export function verdictClaimsIn(comments: { body: string }[]): VerdictClaim[] {
352
+ const out: VerdictClaim[] = [];
353
+ for (const c of comments) {
354
+ for (const line of String(c.body ?? "").split("\n")) {
355
+ // ⛔ A HOLD IS A NON-CLEARANCE THAT NAMES ITS OWN DISPOSITION, so it is rejected outright.
356
+ if (!DECLARES.test(line) || DISCLAIMED.test(line) || HOLD.test(line) || REFUSED.test(line)) continue;
357
+ const m = CLAIM_LINE.exec(line);
358
+ if (!m) continue;
359
+ // ⛔ A WITHDRAWAL IS REJECTED BY POSITION, NOT BY PRESENCE — see isWithdrawal. Testing the
360
+ // verb anywhere on the line discards the superseding verdicts that carry it AFTER the
361
+ // disposition, which fires the watchdog on properly gated merges.
362
+ if (isWithdrawal(line, m.index)) continue;
363
+ const word = m[1]!.toUpperCase().replace(/\s+/g, " ");
364
+ const sha = m[2]!;
365
+ if (word === "READINESS RELEASED") {
366
+ out.push({ result: "PASS", sha, half: "readiness" });
367
+ } else if (word === "CONTENT PASS") {
368
+ out.push({ result: "PASS", sha, half: "content" });
369
+ } else if (word === "FAIL") {
370
+ out.push({ result: "FAIL", sha, half: "whole" });
371
+ } else {
372
+ // bare PASS / FULL PASS — a whole verdict unless the same line withholds readiness
373
+ out.push({ result: "PASS", sha, half: NEGATED_READINESS.test(line) ? "content" : "whole" });
374
+ }
375
+ }
376
+ }
377
+ return out;
378
+ }
379
+
380
+ /**
381
+ * Is there a verdict BOUND TO `head` — a whole one, or both halves? This is the
382
+ * question `unverdicted-merge` actually asks, and it is the seam #964 exposed:
383
+ * that PR published CONTENT PASS and READINESS RELEASED as two separate comments,
384
+ * so a rule scoped to a whole verdict misses a merge that was fully gated.
385
+ *
386
+ * ⭐ A SUPERSEDED VERDICT CANNOT SATISFY THIS, BY CONSTRUCTION RATHER THAN BY A
387
+ * SPECIAL CASE. #965's FAIL cited `ab58f9455` — an object that does not exist —
388
+ * and was corrected in a later comment. Requiring agreement with the head that
389
+ * MERGED means a sha naming any other tree (wrong, stale, or nonexistent) simply
390
+ * does not bind. No "is this superseded" heuristic is needed and none is used.
391
+ */
392
+ export function verdictBoundTo(comments: { body: string }[], head: string): { bound: boolean; why: string } {
393
+ const claims = verdictClaimsIn(comments).filter((v) => shaAgrees(v.sha, head));
394
+ const whole = claims.find((v) => v.half === "whole");
395
+ if (whole) return { bound: true, why: `${whole.result} bound to ${head.slice(0, 7)}` };
396
+ const content = claims.some((v) => v.half === "content");
397
+ const readiness = claims.some((v) => v.half === "readiness");
398
+ if (content && readiness) return { bound: true, why: `CONTENT PASS + READINESS RELEASED bound to ${head.slice(0, 7)} (split halves)` };
399
+ if (content) return { bound: false, why: `only a CONTENT half at ${head.slice(0, 7)} — readiness never released, which is the half that clears a merge` };
400
+ if (readiness) return { bound: false, why: `only a READINESS half at ${head.slice(0, 7)} — no content verdict` };
401
+ return { bound: false, why: `no disposition bound to ${head.slice(0, 7)}` };
402
+ }
403
+
217
404
  export function verdictShasIn(comments: { body: string }[]): { result: string; sha: string }[] {
218
405
  const out: { result: string; sha: string }[] = [];
219
406
  for (const c of comments) {
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
1
3
  /*
2
4
  * ⛔⛆⛆ TWO SEATS RAN THE SAME VERB, GOT `153` AND `138`, AND BOTH SERVERS HONESTLY
3
5
  * REPORTED `versionLabel 0.26.20` — `⟨q-cec42e20⟩`.
@@ -95,9 +97,19 @@ const sideOf = (startedAt: number, installMtime: number): Cohort["side"] =>
95
97
  * read agrees. The cohorts are still returned, so a reader sees what was established as
96
98
  * well as what was not.
97
99
  */
100
+ /** Is a stamped server pid still running? EPERM means it exists and is not ours. */
101
+ export function pidRunning(pid: number): boolean {
102
+ if (!Number.isInteger(pid) || pid <= 0) return false;
103
+ try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === "EPERM"; }
104
+ }
105
+
98
106
  export function detectSpread(
99
107
  identities: ServerIdentity[],
100
108
  installed: InstalledBuild | null,
109
+ // PURE BY DEFAULT: the detector trusts a stamped pid unless the caller supplies a liveness
110
+ // check. The production caller (`list_agents`) passes `pidRunning`; a fixture of a measured
111
+ // incident, whose pids belong to processes long gone, is still placed by its stamps.
112
+ { isRunning = () => true }: { isRunning?: (pid: number) => boolean } = {},
101
113
  ): SpreadVerdict {
102
114
  const uncomparable: Uncomparable[] = [];
103
115
  const placed: { agentId: string; side: Cohort["side"]; module?: string }[] = [];
@@ -120,6 +132,16 @@ export function detectSpread(
120
132
  });
121
133
  continue;
122
134
  }
135
+ // ⟨q-18a719c5⟩ A STAMP OUTLIVES ITS SERVER. If the process that stamped this entry is gone,
136
+ // the module it names is what a DEAD process ran — not what serves the seat now. Unknown,
137
+ // naming the pid, until the seat's next server binds and stamps over it.
138
+ if (typeof id.serverPid === "number" && !isRunning(id.serverPid)) {
139
+ uncomparable.push({
140
+ agentId: id.agentId,
141
+ why: `stamped by server pid ${id.serverPid}, which is no longer running — the entry names a process that is gone, not the one serving this seat`,
142
+ });
143
+ continue;
144
+ }
123
145
  if (installed === null) continue;
124
146
  // ⭐ A DIFFERENT BUILD IS ITS OWN COHORT, never folded into `pre-install`: "which
125
147
  // side of the install did you start" is not a question about a process that does
@@ -156,6 +178,12 @@ export function detectSpread(
156
178
  uncomparable,
157
179
  };
158
180
  }
181
+ // ⟨q-18a719c5⟩ UNKNOWNS BLOCK AGREEMENT, NOT DISAGREEMENT. Two readable seats on different
182
+ // sides of the install is established whatever the unreadable ones run, so it is reported as
183
+ // DIVERGED with the unknowns listed. Only AGREED needs every seat, and that rule (#293) stands.
184
+ if (uncomparable.length && cohorts.length > 1) {
185
+ return { state: "DIVERGED", cohorts, comparable, uncomparable };
186
+ }
159
187
  if (uncomparable.length) {
160
188
  return {
161
189
  state: "CANNOT_COMPARE",
@@ -218,15 +246,35 @@ export function reportSpread(v: SpreadVerdict, installed: InstalledBuild | null)
218
246
  * question is "what build is the answering process running", and the answering process
219
247
  * is the one executing this file.
220
248
  */
249
+ export const COORD_MCP_PACKAGE_NAME = "agent-coord-mcp";
250
+
221
251
  export function installedBuild(
222
252
  fromUrl: string,
223
253
  statSync: (p: string) => { mtimeMs: number },
254
+ readFile: (p: string) => string = (p) => readFileSync(p, "utf8"),
224
255
  ): InstalledBuild | null {
256
+ // ⟨q-18a719c5⟩ THE ROOT IS FOUND BY THE PACKAGE'S OWN IDENTITY, not by a depth. This used to
257
+ // strip one level (`/dist/<file>`), and both production callers live in
258
+ // `dist/tools/registry.js`: the regex never matched, `statSync(".../registry.js/package.json")`
259
+ // threw, and this returned null on every server — `serverSpread` read CANNOT_COMPARE on every
260
+ // fleet and the heartbeat stamp wrote `serverModule: undefined`. A depth count, or the FIRST
261
+ // package.json found, breaks in other layouts (a global npm or homebrew install nests the
262
+ // package under node_modules, a workspace nests it under packages/); the package.json whose
263
+ // `name` is agent-coord-mcp is the package in every one of them. Nothing found stays null.
225
264
  try {
226
- // dist/server-spread.js -> the package root two levels up
227
- const here = new URL(fromUrl).pathname;
228
- const root = here.replace(/\/dist\/[^/]*$/, "");
229
- return { mtime: statSync(`${root}/package.json`).mtimeMs, module: root };
265
+ let dir = path.dirname(decodeURIComponent(new URL(fromUrl).pathname));
266
+ for (let hops = 0; hops < 12; hops++) {
267
+ const pkg = path.join(dir, "package.json");
268
+ try {
269
+ if (JSON.parse(readFile(pkg))?.name === COORD_MCP_PACKAGE_NAME) {
270
+ return { mtime: statSync(pkg).mtimeMs, module: dir };
271
+ }
272
+ } catch { /* no readable package.json here — keep walking */ }
273
+ const up = path.dirname(dir);
274
+ if (up === dir) break;
275
+ dir = up;
276
+ }
277
+ return null;
230
278
  } catch {
231
279
  return null;
232
280
  }
package/src/server.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
3
+ import { readyProfileStartupLine } from "../hooks/submit.mjs";
2
4
  import { randomUUID } from "node:crypto";
3
- import { initTransportFromConfig } from "./transports/index.js";
5
+ import { HERDR, initTransportFromConfig } from "./transports/index.js";
6
+ import { startTailOnBind, stopHerdrTail } from "./tools/herdr-tail.js";
4
7
  import { createServer, IncomingMessage, ServerResponse } from "node:http";
5
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -37,6 +40,7 @@ import {
37
40
  forceUnregisterTool,
38
41
  heartbeatSchema,
39
42
  heartbeatTool,
43
+ stampServerIdentity,
40
44
  joinRoomSchema,
41
45
  joinRoomTool,
42
46
  joinSchema,
@@ -334,6 +338,12 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
334
338
  const via = await guardFirstClaim(claimed, args);
335
339
  bound = claimed;
336
340
  recordSessionBinding(claimed, via);
341
+ // ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
342
+ startTailOnBind(claimed);
343
+ // ⟨q-18a719c5⟩ A server that re-binds WITHOUT `join` (a restart that first calls
344
+ // attach_agent, or any gated tool) serves the seat from now on: stamp the entry if it
345
+ // exists. A seat not yet registered is stamped by the `register` that creates it.
346
+ await stampServerIdentity(claimed);
337
347
  }
338
348
  } else if (bound !== claimed) {
339
349
  throw new Error(
@@ -384,6 +394,8 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
384
394
  const via = await guardFirstClaim(claimed, args);
385
395
  bound = claimed;
386
396
  recordSessionBinding(claimed, via);
397
+ // ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
398
+ startTailOnBind(claimed);
387
399
  } else if (bound !== claimed) {
388
400
  throw new Error(
389
401
  `identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
@@ -550,6 +562,8 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
550
562
  const via = await guardFirstClaim(claimed, args);
551
563
  bound = claimed;
552
564
  recordSessionBinding(claimed, via);
565
+ // ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
566
+ startTailOnBind(claimed);
553
567
  } else if (bound !== claimed) {
554
568
  throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
555
569
  }
@@ -558,8 +572,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
558
572
  if (result && typeof result === "object" && (result as { ok?: unknown }).ok === true) {
559
573
  const to = (result as { to?: unknown }).to;
560
574
  if (typeof to === "string") {
575
+ const from = bound;
561
576
  bound = to;
562
577
  recordSessionBinding(to, "rename");
578
+ // ⛔ THE TAIL MOVES WITH THE IDENTITY. The coordinator's gate measured a renamed herdr seat
579
+ // still tailing its OLD id's inbox — `herdrTail` listed only the old name — so the seat
580
+ // went deaf under its new one. Stop the old, start the new.
581
+ if (from && from !== to) stopHerdrTail(from);
582
+ startTailOnBind(to);
563
583
  }
564
584
  }
565
585
  return jsonResult(result);
@@ -828,6 +848,23 @@ async function main() {
828
848
  if (t.source !== "default") {
829
849
  console.error(`[agent-coord-mcp] transport: ${t.kind} (from ${t.source})`);
830
850
  }
851
+ // ⛔ A HERDR SEAT MUST TAIL ITS OWN INBOX, OR IT IS DEAF TO EVERY TMUX SENDER.
852
+ // The send-time path can only see the SENDER's transport (a module-level singleton), so a
853
+ // tmux seat's server can never type into a herdr pane. The duty belongs to the seat that
854
+ // needs waking — the only party guaranteed to be where it is — and this is where it starts.
855
+ // The env var is ONE way to bind identity, and no seat on this fleet uses it — they bind at
856
+ // `join`. So this branch only covers a server launched pre-bound; the tail's real start is the
857
+ // binding hook below, which runs at every bind. A herdr server with neither simply waits.
858
+ // ⟨q-15d763dc⟩ A herdr server types into panes itself, so an unguarded one says so at start.
859
+ if (t.kind === HERDR) {
860
+ const guardLine = readyProfileStartupLine("agent-coord-mcp");
861
+ if (guardLine) console.error(guardLine.trimEnd());
862
+ }
863
+ if (t.kind === HERDR && process.env.AGENT_COORD_BOUND_AGENT) {
864
+ startTailOnBind(process.env.AGENT_COORD_BOUND_AGENT);
865
+ } else if (t.kind === HERDR) {
866
+ console.error("[agent-coord-mcp] herdr transport: the inbox tail starts when this session binds its identity (join)");
867
+ }
831
868
  } catch (e) {
832
869
  // Loud, and fatal. A server that starts with an unusable transport config
833
870
  // delivers nothing while reporting itself up, and a quiet transport is the
package/src/tools/away.ts CHANGED
@@ -92,6 +92,18 @@ export const COVERAGE_THRESHOLD = "EVERY in-flight lane the clock scores must be
92
92
  * carries the reading's age and labels it stale past this many minutes.
93
93
  */
94
94
  export const COVERAGE_STALE_MINUTES = 60;
95
+ /**
96
+ * ⟨q-1c95f7d4⟩ 5.4 — the tick's own sentence for the announcement and the refusal. A fleet
97
+ * with NO tick source says so (that is this fleet today: six tmux seats, zero herdr), and a
98
+ * fleet with a source that answered for nobody says BLIND rather than saying nothing.
99
+ */
100
+ export const tickSentence = (t?: AwayTick): string => {
101
+ if (!t || t.seats === 0) return " No external tick: no seat has an observer to ask, so every lane above was measured from its branch and the board alone.";
102
+ if (t.readable === 0) return ` External tick BLIND: ${t.seats} seat(s) have an observer and none answered — a source that cannot be read is not a quiet fleet.`;
103
+ const states = Object.entries(t.states).map(([k, v]) => `${v} ${k}`).join(", ");
104
+ return ` External tick: ${t.readable}/${t.seats} seat(s) answered${states ? ` (${states})` : ""} — evidence about the seats, beside the branch axes, never instead of them.`;
105
+ };
106
+
95
107
  export const fmtAge = (ms: number): string => (ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 3_600_000 ? `${Math.floor(ms / 60_000)}m` : ms < 86_400_000 ? `${Math.floor(ms / 3_600_000)}h` : `${Math.floor(ms / 86_400_000)}d`);
96
108
  export function coverageAgeOf(coverage: { at: string }, now = Date.now()): { ageMs: number | null; stale: boolean; label: string } {
97
109
  const at = Date.parse(coverage.at);
@@ -103,10 +115,28 @@ export function coverageAgeOf(coverage: { at: string }, now = Date.now()): { age
103
115
  /** Met when nothing scored is unseen — an empty board (0 of 0) is idle, not blind, and needs no special case. */
104
116
  export const meetsCoverageThreshold = (c: { checked: number; measurable: number }): boolean => c.measurable >= c.checked;
105
117
 
118
+ /**
119
+ * ⟨q-1c95f7d4⟩ Task 5.4 — WHAT THE EXTERNAL TICK CONTRIBUTES TO THE AWAY PRECONDITION.
120
+ *
121
+ * The precondition asks what the clock could actually SEE. A seat observed from OUTSIDE
122
+ * this process — herdr answering `idle | working | blocked` for its pane — is seen in a way
123
+ * a pid and a branch cannot manage: it is the only signal here that separates a thinking
124
+ * lane from a wedged one. So a readable tick counts as measured, through `stall_check`'s
125
+ * own coverage arithmetic, and nothing is special-cased here.
126
+ *
127
+ * ⛔ WHAT IS NOT DONE, DELIBERATELY: a tick NEVER raises coverage for a seat the branch
128
+ * axes could not measure into "observed enough to go away on". It credits the seat it read
129
+ * and nothing else, and a fleet with a tick source that answered for NOBODY is reported as
130
+ * blind — `0 of N readable` is the blind case this verb exists to refuse, not a quiet one.
131
+ */
132
+ export type AwayTick = { seats: number; readable: number; states: Record<string, number>; note: string };
133
+
106
134
  export type AwayCoverage = {
107
135
  checked: number;
108
136
  measurable: number;
109
137
  blind: string[];
138
+ /** ⟨q-1c95f7d4⟩ the external tick behind this coverage number, named so the announcement can say what watched the fleet. */
139
+ tick?: AwayTick;
110
140
  /** ⟨q-5d1c8e04⟩ — standing seats on the board: present, not scored, and therefore not blind. */
111
141
  roles?: number;
112
142
  /** Lanes that declare no per-agent branch: out of the population by their own statement. */
@@ -220,6 +250,7 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
220
250
  try {
221
251
  const r = (await stallCheckTool({ repo })) as unknown as {
222
252
  ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[]; deliberate?: unknown[];
253
+ tick?: { seats?: unknown[]; readable?: number; states?: Record<string, number>; note?: string };
223
254
  boardParse?: { readable: boolean; rowsPresent: number; rowsParsed: number; why: string };
224
255
  };
225
256
  // ⟨q-3d82f1a9⟩ — AN UNREADABLE BOARD IS A NAMED STATE, not "could not measure".
@@ -237,6 +268,9 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
237
268
  checked: r.checked ?? 0,
238
269
  measurable: r.measurable ?? 0,
239
270
  blind: r.blind ?? [],
271
+ tick: r.tick
272
+ ? { seats: r.tick.seats?.length ?? 0, readable: r.tick.readable ?? 0, states: (r.tick.states ?? {}) as Record<string, number>, note: r.tick.note ?? "" }
273
+ : undefined,
240
274
  roles: r.roles?.length ?? 0,
241
275
  deliberate: r.deliberate?.length ?? 0,
242
276
  at: new Date().toISOString(),
@@ -352,7 +386,8 @@ export async function coordAwayTool(args: {
352
386
  `"The clock ran" and "the fleet is observed" are different facts, and this verb must not treat the first as the second. ` +
353
387
  `The usual cause is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref: a path resolves for ` +
354
388
  `git and measures the wrong thing, so the check reports it unmeasurable rather than guessing. Fix those cells and ` +
355
- `coverage returns. If you mean to go anyway, pass acknowledgeBlindFleet:true — it is recorded in the state and in the announcement.`,
389
+ `coverage returns. If you mean to go anyway, pass acknowledgeBlindFleet:true — it is recorded in the state and in the announcement.` +
390
+ tickSentence(coverage.tick),
356
391
  };
357
392
  }
358
393
 
@@ -382,7 +417,8 @@ export async function coordAwayTool(args: {
382
417
  `Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
383
418
  `Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
384
419
  `Decisions logged to ${args.decisionLog}. ` +
385
- `Stall coverage ${coverage.measurable}/${coverage.checked}${!meetsCoverageThreshold(coverage) ? ` — ACKNOWLEDGED BLIND${coverage.measurable === 0 ? ": the clock runs and sees nothing" : ""}${coverage.blind.length ? ` on: ${coverage.blind.join(", ")}` : ""}` : ""}.`,
420
+ `Stall coverage ${coverage.measurable}/${coverage.checked}${!meetsCoverageThreshold(coverage) ? ` — ACKNOWLEDGED BLIND${coverage.measurable === 0 ? ": the clock runs and sees nothing" : ""}${coverage.blind.length ? ` on: ${coverage.blind.join(", ")}` : ""}` : ""}.` +
421
+ tickSentence(coverage.tick),
386
422
  };
387
423
  }
388
424
 
@@ -20,13 +20,15 @@
20
20
  * `[agent-coord] …` banner and the attributed line) but one at a time. Tiers still decide
21
21
  * the banner text. Batching for herdr is Task 5 territory if it is wanted.
22
22
  */
23
- import { statSync } from "node:fs";
23
+ import { mkdirSync, statSync } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
  import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
27
27
  import { ROOT, inboxFile, roomFile } from "../store.js";
28
28
  import { loadLiveTransports } from "./registry.js";
29
+ import { newMessagesIn } from "./jsonl-offsets.js";
29
30
  import type { Message } from "./shared.js";
31
+ export type { Message };
30
32
 
31
33
  type Tier = { formatBatch: (batch: unknown[], agentId: string, rooms: string[]) => string; classifyTier: (m: unknown, opts?: unknown) => string };
32
34
  type PushCursor = { readPushCursor: (root: string, safeId: string) => Record<string, unknown>; writePushCursor: (root: string, safeId: string, c: Record<string, unknown>) => void };
@@ -42,7 +44,64 @@ function loadHooks(): Promise<{ tier: Tier; cursor: PushCursor }> {
42
44
  }
43
45
  const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
44
46
 
45
- export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean };
47
+
48
+ export type Where = { kind: "dm" } | { kind: "room"; chan: string };
49
+
50
+ /**
51
+ * ⭐ ONE RENDERER AND ONE CURSOR RULE, SHARED BY BOTH PATHS. The send-time path
52
+ * (`deliverToHerdrSeats`) and the seat's own tail (`herdr-tail.ts`) must produce the same bytes
53
+ * in the pane and must move the same cursor the same way — a second copy of either is a second
54
+ * truth about what a seat has already seen, and the copy is the one that rots.
55
+ */
56
+ export async function renderForPane(msg: Message, agentId: string, where: Where): Promise<string> {
57
+ const { tier } = await loadHooks();
58
+ const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` };
59
+ return tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []);
60
+ }
61
+
62
+ /**
63
+ * Advance the PUSH cursor past everything currently in the source. Returns whether it moved.
64
+ * ⛔ Called ONLY after a verified delivery — that is the rule this whole module exists to keep.
65
+ */
66
+ export async function advancePushCursor(agentId: string, where: Where, toOffset?: number): Promise<boolean> {
67
+ try {
68
+ const { cursor } = await loadHooks();
69
+ // ⛔ THE CURSOR DIRECTORY MUST EXIST OR THE WRITE FAILS SILENTLY, AND A CURSOR THAT NEVER
70
+ // ADVANCES RE-TYPES THE SAME MESSAGE ON EVERY TICK — a paste loop into a live pane. Found by
71
+ // the tail's own double-delivery test against a fresh bus directory, where `cursors/` had not
72
+ // been created yet: `writePushCursor` threw ENOENT, the `catch` below turned it into
73
+ // `false`, and the delivery looked fine from every other angle.
74
+ mkdirSync(path.join(ROOT, "cursors"), { recursive: true });
75
+ const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
76
+ // `toOffset` is the byte just past the line that was delivered. Without it (the send-time path,
77
+ // where the just-appended message IS the last line) the file size is the same number.
78
+ const size = toOffset ?? statSync(file).size;
79
+ const id = safeId(agentId);
80
+ const c = cursor.readPushCursor(ROOT, id) ?? {};
81
+ if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
82
+ else {
83
+ const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
84
+ offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
85
+ c.roomOffsets = offsets;
86
+ }
87
+ cursor.writePushCursor(ROOT, id, c);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** The push cursor as the pusher stores it, for a caller that needs to read an offset. */
95
+ export async function readPushCursorFor(agentId: string): Promise<Record<string, unknown>> {
96
+ try {
97
+ const { cursor } = await loadHooks();
98
+ return cursor.readPushCursor(ROOT, safeId(agentId)) ?? {};
99
+ } catch {
100
+ return {};
101
+ }
102
+ }
103
+
104
+ export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean; deferred?: string };
46
105
 
47
106
  /** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
48
107
  export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
@@ -56,32 +115,41 @@ export async function deliverToHerdrSeats(msg: Message, recipients: string[], wh
56
115
  if (!marker || marker.transport !== HERDR) continue;
57
116
  if (where.kind === "room" && marker.rooms === false) continue;
58
117
  if (msg.from === agentId) continue;
59
- const { tier, cursor } = await loadHooks();
60
- const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` };
61
- const rendered = tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []);
118
+ // ⛔ ORDER BEFORE SPEED. This path runs in the SENDER's server at send time. If an EARLIER
119
+ // message to this seat is still owed — a paste that failed, which the seat's own tail is
120
+ // retrying — typing this one now and moving the cursor would jump past the owed one, and the
121
+ // tail's retry would find the cursor already beyond it: that message would never be typed.
122
+ // The coordinator's gate named the old call as "still advances the cursor to EOF"; measured,
123
+ // it did exactly that. So: find THIS message after the persisted cursor; if any earlier
124
+ // message to someone else precedes it, defer to the recipient's tail, which delivers in order.
125
+ const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
126
+ const c = await readPushCursorFor(agentId);
127
+ const from = where.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[where.chan] ?? 0);
128
+ const after = newMessagesIn(file, from);
129
+ const at = after.findIndex((m) => m.msg.id === msg.id);
130
+ const owed = at === -1 ? [] : after.slice(0, at).filter((m) => m.msg.from !== agentId);
131
+ if (at === -1 || owed.length > 0) {
132
+ out.push({
133
+ agentId,
134
+ delivered: false,
135
+ cursorAdvanced: false,
136
+ deferred: at === -1
137
+ ? "this message is not after the push cursor (already delivered, or the file moved) — left to the recipient's tail"
138
+ : `${owed.length} earlier message(s) to this seat are still owed — left to the recipient's own tail, which delivers in order`,
139
+ });
140
+ continue;
141
+ }
142
+ const rendered = await renderForPane(msg, agentId, where);
62
143
  // A control never comes through here: send_command hands a herdr seat's control to the
63
144
  // transport's sendControl directly (a raw-vs-rendered branch here was dead and its
64
145
  // mutation survived, so it is gone). Everything delivered here is a rendered message.
65
- let r: { delivered: boolean; error?: string; enters?: number };
146
+ let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean };
66
147
  try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
67
- let cursorAdvanced = false;
68
- if (r.delivered) {
69
- try {
70
- const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
71
- const size = statSync(file).size;
72
- const id = safeId(agentId);
73
- const c = cursor.readPushCursor(ROOT, id) ?? {};
74
- if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
75
- else {
76
- const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
77
- offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
78
- c.roomOffsets = offsets;
79
- }
80
- cursor.writePushCursor(ROOT, id, c);
81
- cursorAdvanced = true;
82
- } catch { cursorAdvanced = false; }
83
- }
84
- out.push({ agentId, delivered: r.delivered, enters: r.enters, error: r.error, cursorAdvanced });
148
+ // ⟨q-15d763dc⟩ Only a VERIFIED delivery moves the cursor; anything less leaves the message owed.
149
+ const verified = r.delivered && r.verified === true;
150
+ // Exactly past THIS message's line — never to the end of the file, which may already hold a later one.
151
+ const cursorAdvanced = verified ? await advancePushCursor(agentId, where, after[at]!.end) : false;
152
+ out.push({ agentId, delivered: verified, enters: r.enters, error: r.error ?? (r.delivered ? "delivered but not verified — cursor held (q-15d763dc)" : undefined), cursorAdvanced });
85
153
  }
86
154
  return out;
87
155
  }