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/hooks/submit.mjs CHANGED
@@ -281,6 +281,168 @@ export function readPaneState(paneText) {
281
281
  return { busy, draft, ghost, styledInputLine };
282
282
  }
283
283
 
284
+ // ⟨q-15d763dc⟩ NO KEY REACHES A PANE UNLESS ITS READY INPUT BOX IS POSITIVELY IDENTIFIED.
285
+ //
286
+ // Measured 2026-09-16 on a real Claude Code pane: with the /model picker open, a push typed its
287
+ // text and pressed Enter. The picker took the Enter, wrote ~/.claude/settings.json, closed, and
288
+ // left an empty `❯` prompt behind, so the push reported verified:true for a message Claude never
289
+ // received. The same Enter on a permission prompt approves a tool call, and a message's text can
290
+ // press a dialog's numeric option keys before any Enter. Any bus writer controls that text.
291
+ // So readiness is decided BEFORE the first keystroke, from the SHAPE of the screen, and every
292
+ // pusher (herdr.ts, tmux-pusher.mjs, coord-pusher.mjs) asks this one function. Do not re-implement it.
293
+ //
294
+ // PROFILES, decided once, from THIS PROCESS's environment at start and nowhere else:
295
+ // claude-code (default) — the only box shape known: a ─ rule, `❯` at column 0, a ─ rule,
296
+ // then at most a few footer lines at the bottom of the screen.
297
+ // Measured on Claude Code v2.1.273. Any other screen is HELD.
298
+ // none — the guard is off. For a harness with no profile yet (Aider, codex,
299
+ // gemini-cli, opencode). Logged at start and surfaced in capabilities.
300
+ // An unrecognised value is NOT "none": a typo must never unguard a seat.
301
+ export const READY_PROFILES = Object.freeze(["claude-code", "none"]);
302
+ export function resolveReadyProfile(raw) {
303
+ if (raw === undefined || raw === "") return { profile: "claude-code", raw: null };
304
+ if (READY_PROFILES.includes(raw)) return { profile: raw, raw };
305
+ return { profile: "claude-code", raw, warning: `AGENT_COORD_READY_PROFILE=${JSON.stringify(raw)} is not one of ${READY_PROFILES.join(", ")} — the guard stays ON (claude-code); a typo never unguards a seat` };
306
+ }
307
+ // Read ONCE, at module load, from process.env. Not re-read per call, so nothing that runs later in
308
+ // the process (a message, a bus file, a tool call setting an env var) can switch the guard off.
309
+ const READY_PROFILE_AT_START = Object.freeze(resolveReadyProfile(process.env.AGENT_COORD_READY_PROFILE));
310
+ export function readyProfile() {
311
+ return READY_PROFILE_AT_START;
312
+ }
313
+
314
+ // The start-up line a pusher or server prints about its guard. Silent when guarded and unambiguous;
315
+ // LOUD when the guard is off or the env value was not understood, so an unguarded seat is visible.
316
+ export function readyProfileStartupLine(who) {
317
+ const p = READY_PROFILE_AT_START;
318
+ if (p.profile === "none") return `[${who}] ⛔ READY-BOX GUARD OFF (AGENT_COORD_READY_PROFILE=none, from this process's env): pushes are typed into panes WITHOUT checking for an input box — any open dialog or permission prompt can receive message text and Enter (q-15d763dc)\n`;
319
+ if (p.warning) return `[${who}] ⚠ ${p.warning}\n`;
320
+ return null;
321
+ }
322
+
323
+ // How many lines may sit below the input box's lower rule. Claude Code draws a footer there
324
+ // (mode line, hints); a dialog, picker or autocomplete menu draws many more.
325
+ export const READY_BOX_MAX_BELOW = 4;
326
+ const RULE_RE = /^─{8,}$/;
327
+ const BOX_LINE_RE = /^❯(?:\s|$)/;
328
+ const MAX_DRAFT_LINES = 20;
329
+
330
+ // Read the screen for Claude Code's input box. Returns:
331
+ // { ready: true, draft, ghost, below, above, busy } — the box is on screen; draft "" means empty
332
+ // { ready: false, reason, unreadable? } — no key may be sent
333
+ // { ready: true, unguarded: true } — profile none: the caller's legacy path
334
+ // `screen` may be plain or styled (herdr --format ansi, tmux capture-pane -e).
335
+ export function readReadyBox(screen, profile = READY_PROFILE_AT_START.profile) {
336
+ if (profile === "none") return { ready: true, unguarded: true };
337
+ if (profile !== "claude-code") return { ready: false, reason: `unknown ready profile ${JSON.stringify(profile)} — held` };
338
+ if (screen === null || screen === undefined) return { ready: false, unreadable: true, reason: "the pane could not be read — no key was sent" };
339
+ const styled = String(screen).replace(/\r/g, "").split("\n");
340
+ const plain = styled.map((l) => stripAnsi(l).replace(/ /g, " ").replace(/\s+$/, ""));
341
+ let end = plain.length;
342
+ while (end > 0 && plain[end - 1].trim() === "") end--;
343
+ let p = -1;
344
+ for (let i = end - 1; i >= 0; i--) if (plain[i].includes("❯")) { p = i; break; }
345
+ if (p < 0) return { ready: false, reason: "no ❯ on screen — not Claude Code's input box (another harness, a shell, or a dialog without one)" };
346
+ if (!BOX_LINE_RE.test(plain[p])) return { ready: false, reason: `the last ❯ on screen is not at column 0 (${JSON.stringify(plain[p].trim().slice(0, 40))}) — a dialog, picker or menu row, not the input box` };
347
+ if (p === 0 || !RULE_RE.test(plain[p - 1].trim())) return { ready: false, reason: "the ❯ line has no ─ rule directly above it — not the input box" };
348
+ let q = -1;
349
+ for (let i = p + 1; i < end && i <= p + MAX_DRAFT_LINES; i++) {
350
+ if (RULE_RE.test(plain[i].trim())) { q = i; break; }
351
+ if (plain[i].includes("❯")) break;
352
+ }
353
+ if (q < 0) return { ready: false, reason: "the ❯ line has no ─ rule below it — not the input box" };
354
+ const belowLines = plain.slice(q + 1, end).filter((l) => l.trim() !== "");
355
+ if (belowLines.some((l) => RULE_RE.test(l.trim()))) return { ready: false, reason: "a second ruled box below the input — a dialog or menu is open" };
356
+ let real = "";
357
+ let ghost = "";
358
+ for (let i = p; i < q; i++) {
359
+ const part = partitionStyledLine(styled[i]);
360
+ real += " " + stripAnsi(part.real).replace(/ /g, " ");
361
+ ghost += " " + part.ghost;
362
+ }
363
+ const content = squash(real.replace(/^\s*❯/, ""));
364
+ const placeholder = PLACEHOLDER_PATTERN();
365
+ const draft = placeholder && new RegExp(placeholder).test(content) ? "" : content;
366
+ const busyRe = BUSY_PATTERN();
367
+ return {
368
+ ready: true,
369
+ draft,
370
+ ghost: squash(ghost) || null,
371
+ below: belowLines.length,
372
+ above: plain.slice(0, p - 1),
373
+ busy: busyRe ? new RegExp(busyRe).test(belowLines.join("\n")) : null,
374
+ };
375
+ }
376
+
377
+ // May a keystroke be sent at all? Ready box, EMPTY input (typing onto someone's draft concatenates
378
+ // with it), and nothing drawn below the footer. `null` = yes; otherwise the reason nothing was sent.
379
+ export function refusalBeforeKeys(box) {
380
+ if (!box.ready) return box.reason;
381
+ if (box.unguarded) return null;
382
+ if (box.draft) return `the input box already holds unsent text (${JSON.stringify(box.draft.slice(0, 40))}) — typing would join it`;
383
+ if (box.below > READY_BOX_MAX_BELOW) return `${box.below} lines drawn below the input box (the footer is at most ${READY_BOX_MAX_BELOW}) — a menu or dialog is open`;
384
+ return null;
385
+ }
386
+
387
+ // The pushers' form of refusalBeforeKeys: a pasteAndSubmit-shaped refusal, or null to proceed.
388
+ function heldBeforeKeys(screen, target) {
389
+ const box = readReadyBox(screen);
390
+ const refusal = refusalBeforeKeys(box);
391
+ if (!refusal) return null;
392
+ return {
393
+ submitted: false,
394
+ verified: !box.unreadable, // true: we DID read the screen, and it is not a ready input box
395
+ pasted: false,
396
+ held: true,
397
+ attempts: 0,
398
+ reason: `held, nothing pasted into pane '${target}': ${refusal}`,
399
+ };
400
+ }
401
+
402
+ // Did the typed payload land IN the box (and not in whatever else might have taken the keys)?
403
+ export function boxHoldsPayload(box, payload) {
404
+ if (!box.ready || box.unguarded || !box.draft) return false;
405
+ if (isPasteChip(box.draft)) return true;
406
+ const needle = squash(payload);
407
+ if (!needle) return false;
408
+ const head = needle.slice(0, 32);
409
+ return box.draft.includes(head) || (needle.startsWith(box.draft) && box.draft.length >= 8);
410
+ }
411
+
412
+ // After Enter: the box is back and EMPTY, and the transcript's LAST user-message echo is this
413
+ // payload and is NEW. An empty box alone proves nothing: a dialog that ate the Enter and closed
414
+ // leaves exactly that.
415
+ //
416
+ // THE ECHO SHAPE, measured on a live Claude Code v2.1.273 pane that the tmux pusher had delivered
417
+ // into: a submitted message is drawn as `❯ <first line>` at column 0, its further lines indented
418
+ // beneath, then a blank line; a message queued behind a running turn is drawn ` ❯ <first line>`
419
+ // just above the box. Bus renders often share a first line ("[agent-coord] +1 routine …"), so
420
+ // "a line carrying the head appeared" cannot be a set lookup: an older identical echo would
421
+ // satisfy it. The whole echo block (the echo and its lines up to the first blank) must differ from
422
+ // the one the screen showed before any key was sent. Spinner and status lines never enter it.
423
+ const ECHO_RE = /^\s{0,2}❯ /;
424
+ function lastEchoBlock(above) {
425
+ const lines = above ?? [];
426
+ let i = lines.length - 1;
427
+ while (i >= 0 && !ECHO_RE.test(lines[i])) i--;
428
+ if (i < 0) return null;
429
+ const block = [lines[i]];
430
+ for (let j = i + 1; j < lines.length && lines[j].trim() !== "" && /^\s/.test(lines[j]); j++) block.push(lines[j]);
431
+ return block.map((l) => squash(l)).join("\n");
432
+ }
433
+ export function transcriptGained(before, after, payload) {
434
+ if (!after.ready || after.unguarded || after.draft) return false;
435
+ const now = lastEchoBlock(after.above);
436
+ if (now === null) return false;
437
+ const first = now.split("\n")[0];
438
+ const line = squash(String(payload ?? "").split("\n").find((l) => l.trim()) ?? "");
439
+ const head = line.slice(0, 24);
440
+ // A short payload (a control command such as /clear) must match the echo exactly, not as a substring.
441
+ const matches = head.length >= 8 ? first.includes(head) : line !== "" && squash(first.replace(/^❯/, "")) === line;
442
+ if (!PASTE_CHIP_RE.test(first) && !matches) return false;
443
+ return now !== lastEchoBlock(before.above);
444
+ }
445
+
284
446
  // Submit a CONTROL command (/clear, /compact, /reload-skills) — the path that must actually
285
447
  // run, not merely arrive.
286
448
  //
@@ -309,11 +471,18 @@ export async function submitControl(deps, payload) {
309
471
 
310
472
  const idleBudget = CONTROL_IDLE_WAIT_MS();
311
473
  const deadline = Date.now() + idleBudget;
312
- let state = readPaneState(capture());
474
+ let screen = capture();
475
+ let state = readPaneState(screen);
313
476
  while (state.busy === true && idleBudget > 0 && Date.now() < deadline) {
314
477
  await sleep(VERIFY_POLL_MS() * 5);
315
- state = readPaneState(capture());
478
+ screen = capture();
479
+ state = readPaneState(screen);
316
480
  }
481
+ // ⟨q-15d763dc⟩ No key before the ready input box is identified, read from the SAME screen the busy
482
+ // and draft guards below answer from. A dialog or picker row also matches PROMPT_PATTERN, so
483
+ // without this it would read as a draft at best, and as an empty input at worst.
484
+ const box = readReadyBox(screen);
485
+ if (!box.ready) return heldBeforeKeys(screen, target);
317
486
  if (state.busy === true) {
318
487
  return {
319
488
  submitted: false,
@@ -347,7 +516,9 @@ export async function submitControl(deps, payload) {
347
516
  `Styled input line: ${JSON.stringify(String(state.styledInputLine ?? "").slice(0, 160))}`,
348
517
  };
349
518
  }
350
- return pasteAndSubmit(deps, payload, { bracketed: false, verify: true });
519
+ const below = refusalBeforeKeys(box);
520
+ if (below) return heldBeforeKeys(screen, target);
521
+ return pasteAndSubmit(deps, payload, { bracketed: false, verify: true, screen });
351
522
  }
352
523
 
353
524
  // Paste a payload into the target pane and submit it.
@@ -365,10 +536,16 @@ export async function submitControl(deps, payload) {
365
536
  // dropped or refused — no retry, no receipt of failure. Control commands
366
537
  // already verified; peer traffic gets the same loop now. Pass
367
538
  // `verify:false` explicitly to opt out (unknown-TUI / test escape hatch).
368
- export async function pasteAndSubmit(deps, payload, { bracketed = false, verify } = {}) {
539
+ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify, screen } = {}) {
369
540
  const { run, runStdin, target, buffer } = deps;
370
541
  const doVerify = verify === undefined ? bracketed : verify;
371
542
 
543
+ // ⟨q-15d763dc⟩ NOTHING IS PASTED until the screen is the ready, empty input box. The paste is held
544
+ // as firmly as the Enter: a digit pasted into an open dialog selects one of its options.
545
+ // `screen` is submitControl's own guard read, so a control command costs no second capture.
546
+ const held = heldBeforeKeys(screen ?? captureStyled(run, target), target);
547
+ if (held) return held;
548
+
372
549
  await runStdin(["load-buffer", "-b", buffer, "-"], payload);
373
550
  const paste = run(["paste-buffer", ...(bracketed ? ["-p"] : []), "-b", buffer, "-t", target, "-d"]);
374
551
  if (paste.status !== 0) {
@@ -383,11 +560,24 @@ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify
383
560
  // false — input line readable the whole time and the payload never
384
561
  // appeared; refuse the Enter and say so (honest timeout, not a
385
562
  // blind CR into someone's session).
386
- // null — no readable input line (unknown TUI / capture failed); fall back
387
- // to the legacy fixed-delay path — unknown never blocks delivery.
563
+ // null — no readable input line (capture failed, or the box went away
564
+ // after the paste). ⟨q-15d763dc⟩ This used to fall back to a blind
565
+ // Enter ("unknown never blocks delivery"). Unreadable now means HELD:
566
+ // an Enter nobody looked before is the Enter that lands in a dialog.
388
567
  const floor = sleep(ENTER_DELAY_MS());
389
568
  const landed = await pollUntilLanded(deps, payload);
390
569
  await floor;
570
+ if (landed === null) {
571
+ return {
572
+ submitted: false,
573
+ verified: false,
574
+ pasted: true,
575
+ attempts: 0,
576
+ reason:
577
+ `pasted into pane '${target}', but its input line could not be read afterwards (capture failed, or no line matched AGENT_COORD_PROMPT_PATTERN) — Enter NOT sent ` +
578
+ `(an unreadable screen is held, never answered with a blind Enter; check the pane for a stranded draft)`,
579
+ };
580
+ }
391
581
  if (landed === false) {
392
582
  return {
393
583
  submitted: false,
@@ -399,17 +589,34 @@ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify
399
589
  `and check the pane for a stranded draft before resending)`,
400
590
  };
401
591
  }
592
+ // ⟨q-15d763dc⟩ ONE Enter, sent right after the landing read saw this payload in the input. The
593
+ // unconditional second Enter that used to follow it (150 ms later, no screen read) is gone: it
594
+ // landed in whatever opened after the first (qa's FAIL on #364). A menu that swallows the first
595
+ // Enter is answered by the retry below, which looks at the screen before every key.
402
596
  const e1 = run(["send-keys", "-t", target, "Enter"]);
403
597
  if (e1.status !== 0) throw new Error(`tmux send-keys: ${String(e1.stderr ?? "").trim()}`);
404
- await sleep(ENTER_GAP_MS());
405
- run(["send-keys", "-t", target, "Enter"]);
406
598
 
407
599
  if (!doVerify) return { submitted: true, verified: false, attempts: 1 };
408
600
 
409
601
  const retries = ENTER_RETRIES();
410
602
  for (let attempt = 1; ; attempt++) {
411
603
  const outcome = await pollUntilGone(deps, payload);
412
- if (outcome === false) return { submitted: true, verified: true, attempts: attempt };
604
+ if (outcome === false) {
605
+ // "The payload left the input" is only a submit if the screen is still the ready box. A
606
+ // picker's `❯ 2. …` row also matches PROMPT_PATTERN and reads as an emptied input.
607
+ const after = readReadyBox(captureStyled(run, target));
608
+ if (!after.ready || (!after.unguarded && after.draft)) {
609
+ return {
610
+ submitted: false,
611
+ verified: false,
612
+ attempts: attempt,
613
+ reason:
614
+ `Enter sent to pane '${target}', but the screen after it is not the ready input box ` +
615
+ `(${after.ready ? "the box holds other text" : after.reason}) — a dialog may have taken the Enter; not verified`,
616
+ };
617
+ }
618
+ return { submitted: true, verified: true, attempts: attempt };
619
+ }
413
620
  if (outcome === null) {
414
621
  return {
415
622
  submitted: false,
@@ -440,6 +647,17 @@ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify
440
647
  // since. Under the old tail-window check this fired on EVERY successful
441
648
  // submit — three Enters into a pane that had already run the command.
442
649
  await sleep(ENTER_GAP_MS());
650
+ // The loop head read the input; the retry Enter also needs the screen to still BE the ready box
651
+ // holding this payload, or it is the key that lands in a dialog.
652
+ const box = readReadyBox(captureStyled(run, target));
653
+ if (!box.unguarded && !boxHoldsPayload(box, payload)) {
654
+ return {
655
+ submitted: false,
656
+ verified: false,
657
+ attempts: attempt,
658
+ reason: `retry Enter NOT sent to pane '${target}': the screen is no longer the ready box holding the command (${box.ready ? "the box changed" : box.reason})`,
659
+ };
660
+ }
443
661
  run(["send-keys", "-t", target, "Enter"]);
444
662
  }
445
663
  }
package/hooks/tier.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { isGateRunner } from "./roles.mjs";
13
13
  import { replayMarker } from "./replay.mjs";
14
+ import { neutralizeControls } from "./control-bytes.mjs";
14
15
 
15
16
  // Typed protocol record → tier (Phase 8). The prefix table below is the same
16
17
  // vocabulary parsed out of text; reading the field instead removes the parse
@@ -222,7 +223,12 @@ export function injectLine(m) {
222
223
  // is still `[tag HH:MM from] ` and the marker is part of the TEXT, which is
223
224
  // correct — it is something the reader must see, not metadata about routing.
224
225
  text = `${replayMarker(m.replay)}${text}`;
225
- return ` [${tag} ${hhmm} ${m.from}] ${text}`;
226
+ // ⟨q-e5cb3538⟩ — LAST, over the WHOLE line, so every field that reaches a pane is covered
227
+ // (tag, from, text, the record type in the handle) on every push path that renders here:
228
+ // herdr delivery, the tmux pusher and the remote pusher. A control byte in content is a
229
+ // keystroke in the recipient's session; it becomes a visible escape instead. Content line
230
+ // feeds are kept — see hooks/control-bytes.mjs for why, and for the byte class.
231
+ return neutralizeControls(` [${tag} ${hhmm} ${m.from}] ${text}`);
226
232
  }
227
233
 
228
234
  // Which room is THIS agent's project room, derived from the bus naming
@@ -103,7 +103,7 @@ import { isCi } from "./roles.mjs";
103
103
  import { mergeTransportMarker } from "./marker.mjs";
104
104
  import { readPushCursor, writePushCursor } from "./push-cursor.mjs";
105
105
  import { priorDeliveries, replayInfo } from "./replay.mjs";
106
- import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
106
+ import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl, readyProfileStartupLine } from "./submit.mjs";
107
107
 
108
108
  const AGENT_ID = process.env.AGENT_COORD_ID;
109
109
  const TMUX_TARGET = process.env.AGENT_COORD_TMUX_TARGET;
@@ -768,6 +768,10 @@ function submitControlCommand(payload) {
768
768
 
769
769
  // Publish transport marker so list_agents can show this agent is push-capable.
770
770
  writeTransportMarker();
771
+ {
772
+ const guardLine = readyProfileStartupLine("tmux-pusher");
773
+ if (guardLine) process.stderr.write(guardLine);
774
+ }
771
775
  let markerCleaned = false;
772
776
  const cleanupMarker = () => {
773
777
  if (markerCleaned) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.26.23",
3
+ "version": "0.26.25",
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": {
@@ -37,7 +37,7 @@
37
37
 
38
38
  import { hostname } from "node:os";
39
39
  import { spawn, spawnSync } from "node:child_process";
40
- import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "../hooks/submit.mjs";
40
+ import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl, readyProfileStartupLine } from "../hooks/submit.mjs";
41
41
  // The pane's parse-contract line, single-sourced — see the note above formatBatch.
42
42
  import { injectLine } from "../hooks/tier.mjs";
43
43
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -58,6 +58,10 @@ const REFRESH_MS = parseInt(argv["refresh-ms"] ?? "30000", 10);
58
58
  if (!SERVER) die("--server (or AGENT_COORD_SERVER) is required");
59
59
  if (!TOKEN) die("--token (or AGENT_COORD_TOKEN) is required");
60
60
  if (!AGENT_ID) die("--agent (or AGENT_COORD_ID) is required");
61
+ {
62
+ const guardLine = readyProfileStartupLine("coord-pusher");
63
+ if (guardLine) process.stderr.write(guardLine);
64
+ }
61
65
  if (!TMUX_TARGET) die("--tmux (or AGENT_COORD_TMUX_TARGET) is required");
62
66
 
63
67
  const SAFE_ID = AGENT_ID.replace(/[^a-zA-Z0-9._-]/g, "_");
@@ -33,25 +33,40 @@
33
33
  * Probes call behaviour. The version travels beside the answer as CONTEXT and
34
34
  * is labelled as such.
35
35
  */
36
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
37
+ import { tmpdir } from "node:os";
38
+ import path from "node:path";
36
39
  import { prRefsIn } from "./tools/record-events.js";
37
40
  import { EVENT_KIND_IDS } from "./tools/event-kinds.js";
38
41
  import { suggestRecordType, typedRecordMode } from "./typed-records.js";
39
42
  import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
40
43
  import { recordAuthorityFor } from "./roles.js";
44
+ // ⟨q-e5cb3538⟩ The renderer and the byte class live in hooks/, shared with both pushers.
45
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
46
+ import { injectLine } from "../hooks/tier.mjs";
47
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
48
+ import { findControlByte } from "../hooks/control-bytes.mjs";
49
+ // ⟨q-15d763dc⟩ The ready-box guard's profile, as this process read it from its own env at start.
50
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
51
+ import { readyProfile } from "../hooks/submit.mjs";
41
52
  import { subscriptionHealth } from "./tools/events.js";
42
53
  import {
43
54
  configuredTransport,
44
55
  runningTransport,
45
56
  isTmuxKind,
46
57
  targetOf,
58
+ herdrMarkerPid,
47
59
  type TransportKind,
48
60
  } from "./transports/index.js";
49
- import { readAllTransportMarkers } from "./tools/registry.js";
61
+ import { readAllTransportMarkers, markerHoldsLiveProcess } from "./tools/registry.js";
50
62
  import { queueWriteSchema } from "./tools/queue-write.js";
51
63
  import { treeProvenance } from "./tools/tree-provenance.js";
52
64
  import { detectSpread } from "./server-spread.js";
65
+ import { answeringServerIdentity } from "./tools/registry.js";
53
66
  import { HerdrTransport, herdrKeyName } from "./transports/herdr.js";
54
- import { HERDR } from "./transports/types.js";
67
+ import { tickVerdict } from "./tools/tick.js";
68
+ import { ensureHerdrTail, herdrTailState, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
69
+ import { HERDR, TICK_READS_AS, TICK_STORED_AS, type TickState } from "./transports/types.js";
55
70
 
56
71
  export type ProbeResult = {
57
72
  id: string;
@@ -237,6 +252,35 @@ const PROBES: Probe[] = [
237
252
  };
238
253
  },
239
254
  },
255
+ {
256
+ // ⟨q-b607005a⟩ — a WRITER must not impose this queue's id grammar on a
257
+ // document that does not use it. Measured on a consumer fleet 2026-09-16: filing ONE item
258
+ // stamped `⟨q-…⟩` onto ALL 168 rows of a queue keyed by `[Q-nnn]` and broke
259
+ // two green guards. The property is not "it stamps" — stamping a queue that
260
+ // ALREADY records ids is correct and must not regress. It is that the
261
+ // document's own prior art decides, and the row THIS CALL authored is the
262
+ // exception that keeps the absorption defect closed.
263
+ id: "stamp-respects-foreign-grammar",
264
+ since: "0.26.24",
265
+ run: () => {
266
+ const head = ["---", 'title: "Queue"', "---", "", "## Queue", ""];
267
+ const foreign = parseWorkDoc(head.concat(["- [ ] (P1) [Q-001] a consumer's own convention"]).join("\n"));
268
+ const native = parseWorkDoc(
269
+ head.concat(["- [ ] (P1) ⟨q-11111111⟩ records its id", "- [ ] (P2) appended by hand"]).join("\n"),
270
+ );
271
+ const foreignOwn = queueItemsOf(foreign)[0]?.id ?? "";
272
+ const left = stampQueueIds(foreign).stamped.length;
273
+ const owned = stampQueueIds(foreign, { own: [foreignOwn] }).stamped.length;
274
+ const absorbed = stampQueueIds(native).stamped.length;
275
+ const present = left === 0 && owned === 1 && absorbed === 1;
276
+ return {
277
+ present,
278
+ evidence:
279
+ `foreign doc, not ours -> stamped ${left} · foreign doc, our own row -> stamped ${owned} · ` +
280
+ `doc that already records ids -> absorbed ${absorbed} -> present=${present}`,
281
+ };
282
+ },
283
+ },
240
284
  {
241
285
  // #293 — `detectSpread` did not exist before 0.26.22. The property is not
242
286
  // "it answers": an UNREADABLE seat must poison the verdict rather than be
@@ -272,6 +316,61 @@ const PROBES: Probe[] = [
272
316
  return { present, evidence: `"⛔ Blocked — was 🚧 …" in flight: ${blocked} (0.26.21 said true) · "🔍 In Review": ${review} -> present=${present}` };
273
317
  },
274
318
  },
319
+ {
320
+ // ⛔ A HERDR SEAT'S OWN SERVER TAILS ITS INBOX. The first version of this probe checked
321
+ // `typeof startHerdrTail === "function"` — which read PRESENT on six seats while the tail ran
322
+ // on none, the exact lie this verb exists to refuse. It now STARTS a tail against a scripted
323
+ // herdr transport and asserts the process registry reports it running, is idempotent on a
324
+ // second bind, and reports it gone after a stop — plus the line reader's byte offsets, which
325
+ // are what stop a later held message being skipped. Runtime truth for THIS seat is reported
326
+ // separately, under `herdrTail`, because a probe answers "can this build" and not "is it".
327
+ id: "herdr-inbox-tail",
328
+ since: "0.26.25",
329
+ run: () => {
330
+ const probeId = `__probe-herdr-tail-${process.pid}`;
331
+ const scripted = { kind: HERDR } as unknown as Parameters<typeof ensureHerdrTail>[1] extends infer O ? O extends { transport?: infer T } ? T : never : never;
332
+ const dir = mkdtempSync(path.join(tmpdir(), "probe-tail-"));
333
+ try {
334
+ const first = ensureHerdrTail(probeId, { transport: scripted, pollMs: 3_600_000, tail: async () => ({ delivered: [], held: [] }) });
335
+ const again = ensureHerdrTail(probeId, { transport: scripted, pollMs: 3_600_000, tail: async () => ({ delivered: [], held: [] }) });
336
+ const seen = herdrTailState().some((s) => s.agentId === probeId && s.running);
337
+ const stopped = stopHerdrTail(probeId);
338
+ const gone = !herdrTailState().some((s) => s.agentId === probeId);
339
+ const f = path.join(dir, "inbox.jsonl");
340
+ const a = `${JSON.stringify({ id: "a" })}\n`;
341
+ writeFileSync(f, `${a}${JSON.stringify({ id: "b" })}\n{"id":"hal`);
342
+ const read = newMessagesIn(f, 0);
343
+ const offsets = read.map((m) => m.end).join(",") === `${Buffer.byteLength(a)},${Buffer.byteLength(a) + Buffer.byteLength(`${JSON.stringify({ id: "b" })}\n`)}`;
344
+ const present = first.started && !again.started && again.running && seen && stopped && gone && read.length === 2 && offsets;
345
+ return { present, evidence: `ensureHerdrTail started=${first.started} · second bind started=${again.started} running=${again.running} · registry running=${seen} · stop=${stopped} gone=${gone} · reader ${read.length} whole lines with exact end offsets=${offsets} -> present=${present}` };
346
+ } finally {
347
+ stopHerdrTail(probeId);
348
+ rmSync(dir, { recursive: true, force: true });
349
+ }
350
+ },
351
+ },
352
+ {
353
+ // ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — the external tick, probed by CALLING the code that
354
+ // decides what a reading MEANS (every probe here is synchronous, so the async read is
355
+ // exercised by its test suite and this asserts the decision layer plus the wiring):
356
+ // a `blocked` reading is a measured HIT, an `idle` one is coverage and NOT a hit, an
357
+ // unreadable one is neither, and herdr's measured write/read asymmetry is in place.
358
+ id: "external-tick-evidence",
359
+ since: "0.26.24",
360
+ run: () => {
361
+ const seat = (state: TickState) => ({ agentId: "probe", transport: HERDR, readable: true as const, state, source: "herdr pane w0:p0" });
362
+ const blocked = tickVerdict(seat("blocked"));
363
+ const idle = tickVerdict(seat("idle"));
364
+ const blind = tickVerdict({ agentId: "probe", transport: HERDR, readable: false as const, why: "no agent there" });
365
+ const wired = typeof new HerdrTransport().readTick === "function" && typeof new HerdrTransport().publishTick === "function";
366
+ const stored = TICK_STORED_AS.idle === "done" && TICK_READS_AS.done === "idle";
367
+ const present = blocked?.hit === true && idle?.hit === false && idle?.measured === true && blind?.measured === false && wired && stored;
368
+ return {
369
+ present,
370
+ evidence: `tickVerdict blocked -> hit=${blocked?.hit} · idle -> hit=${idle?.hit} measured=${idle?.measured} · unreadable -> measured=${blind?.measured} · readTick/publishTick wired=${wired} · TICK_STORED_AS.idle="${TICK_STORED_AS.idle}" -> present=${present}`,
371
+ };
372
+ },
373
+ },
275
374
  {
276
375
  // Phase 5.4 Task 4 (0.26.23) — the herdr transport exists and refuses by name: a scripted
277
376
  // dead pane reads dead from herdr's own reply, and a tmux key name is refused, not typed.
@@ -290,6 +389,72 @@ const PROBES: Probe[] = [
290
389
  return { present, evidence: `HerdrTransport.kind=${scripted.kind} · scripted pane_not_found -> paneExists=${exists} · herdrKeyName("C-u").ok=${key.ok} -> present=${present}` };
291
390
  },
292
391
  },
392
+ {
393
+ // ⟨q-e5cb3538⟩ (0.26.25) — a control byte in message content never reaches a pane as a
394
+ // keystroke: the renderer every push path uses shows it as a visible escape, and a new send
395
+ // carrying one is refused at ingress. Measured on qa's payload (CR, ESC[201~, ETX) plus DEL
396
+ // and C1, rendered through THIS process's hooks — a seat whose server predates the fix answers
397
+ // false here, which is the question a release is delivered by.
398
+ id: "pane-render-neutralizes-controls",
399
+ since: "0.26.25",
400
+ run: () => {
401
+ const body = "X1\rX2\x1b[201~X3\x03X4\x7f\u0085";
402
+ const line = String(injectLine({ ts: 0, tag: "DM", from: "probe", text: body }));
403
+ const leaked = (line.match(/[\x00-\x09\x0b-\x1f\x7f\u0080-\u009f]/g) ?? []).length;
404
+ const visible = line.includes("X1\\rX2\\x1b[201~X3\\x03X4\\x7f\\u0085");
405
+ const refused = findControlByte({ text: body }) !== null && findControlByte({ text: "ok\n\tok" }) === null;
406
+ const present = leaked === 0 && visible && refused;
407
+ return { present, evidence: `rendered qa's payload + DEL + C1 -> leaked ${leaked} control byte(s) · escapes visible=${visible} · ingress refuses it and passes LF/TAB=${refused} -> present=${present}` };
408
+ },
409
+ },
410
+ {
411
+ // ⟨q-abd88dd4⟩ — a herdr marker's pid is chosen for the pre-herdr readers that cannot be
412
+ // patched: 1 only where this process may not signal pid 1 (EPERM), 0 otherwise, and this
413
+ // build never reads it as a process. Both functions are new in 0.26.25.
414
+ id: "herdr-marker-survives-pre-herdr-readers",
415
+ since: "0.26.25",
416
+ run: () => {
417
+ const eperm = () => { const e = new Error("EPERM") as NodeJS.ErrnoException; e.code = "EPERM"; throw e; };
418
+ const notSignalable = herdrMarkerPid(eperm).pid;
419
+ const signalable = herdrMarkerPid(() => true).pid;
420
+ const holds = markerHoldsLiveProcess({ agentId: "probe", transport: HERDR, pid: 1, since: 0 });
421
+ const present = notSignalable === 1 && signalable === 0 && holds === false;
422
+ return { present, evidence: `kill(1,0) EPERM -> pid ${notSignalable} · kill(1,0) permitted (root/container) -> pid ${signalable} · herdr marker pid 1 holds a live process: ${holds} -> present=${present}` };
423
+ },
424
+ },
425
+ {
426
+ // ⟨q-3cd5a77d⟩ — the result word counts only in the CLAIM position. A seat on an older server
427
+ // reads the #359 routing header (PASS in the PR title) as the coordinator gating it PASS.
428
+ id: "gate-claim-needs-claim-position",
429
+ since: "0.26.25",
430
+ run: () => {
431
+ const sha = "0826519e043add993c5d757908c1903239542160";
432
+ const line = (text: string) => JSON.stringify({ ts: 1, from: "probe", text, record: { type: "go", payload: {}, cites: [{ kind: "pr", ref: "#359" }] } });
433
+ const routing = gateClaimsIn(line(`GATE, prioritised: #359 (test-all prints PASS after FAILED) @ ${sha}`), "359", sha).length;
434
+ const typed = gateClaimsIn(line(`QA GATE — **PASS** @ \`${sha}\``), "359", sha).length;
435
+ const present = routing === 0 && typed === 1;
436
+ return { present, evidence: `routing header with PASS in the title -> ${routing} claim(s) · typed QA GATE — **PASS** @ sha -> ${typed} -> present=${present}` };
437
+ },
438
+ },
439
+ {
440
+ // ⟨q-18a719c5⟩ The spread can place a seat at all: the identity THIS server stamps names its
441
+ // module (resolved by package name, through the real caller in dist/tools/registry.js), a
442
+ // stamp whose pid is gone reads unknown, and unknowns block AGREED but not DIVERGED. Before
443
+ // 0.26.25 the module resolved to undefined on every server.
444
+ id: "server-spread-places-seats",
445
+ since: "0.26.25",
446
+ run: () => {
447
+ const me = answeringServerIdentity();
448
+ const installed = typeof me.serverModule === "string" ? { mtime: 1_000, module: me.serverModule } : null;
449
+ const seat = (agentId: string, startedAt: number, pid = 101) => ({ agentId, serverPid: pid, serverStartedAt: startedAt, serverModule: me.serverModule });
450
+ const alive = (pid: number) => pid !== 999;
451
+ const stale = installed ? detectSpread([seat("gone", 2_000, 999), seat("b", 3_000)], installed, { isRunning: alive }) : null;
452
+ const split = installed ? detectSpread([seat("old", 500), seat("new", 3_000), { agentId: "mute" }], installed, { isRunning: alive }) : null;
453
+ const staleUnknown = !!stale?.uncomparable.some((u) => u.agentId === "gone" && /no longer running/.test(u.why));
454
+ const present = typeof me.serverModule === "string" && staleUnknown && split?.state === "DIVERGED";
455
+ return { present, evidence: `module ${me.serverModule ?? "UNRESOLVED"} · dead stamp -> unknown: ${staleUnknown} · split + unstamped seat -> ${split?.state ?? "n/a"} -> present=${present}` };
456
+ },
457
+ },
293
458
  {
294
459
  id: "record-authority",
295
460
  since: "0.24.0",
@@ -472,7 +637,8 @@ export function probeCapabilities(context: { module: string; versionLabel: strin
472
637
 
473
638
  import { resolveServerIdentity } from "./server-identity.js";
474
639
  import { seatBuildOf, installedFrom, psReader, type SeatBuild } from "./tools/seat-build.js";
475
- import { isInFlightStatus } from "./tools/stall.js";
640
+ import { isInFlightStatus, gateClaimsIn } from "./tools/stall.js";
641
+ import { parseWorkDoc, queueItemsOf, stampQueueIds } from "@davidbalzan/groundwork-seam";
476
642
 
477
643
  export const capabilitiesSchema = {} as const;
478
644
 
@@ -556,11 +722,24 @@ export async function seatBuilds(): Promise<{ installed: { module: string; hookP
556
722
  };
557
723
  }
558
724
 
725
+ /**
726
+ * ⟨q-15d763dc⟩ Is THIS process's pane push guarded? `guarded:false` means AGENT_COORD_READY_PROFILE=none
727
+ * was set in this server's env at start: its pushes type into panes without checking for Claude
728
+ * Code's input box. Stated here so an unguarded seat is visible fleet-wide, not only in a log.
729
+ */
730
+ function readyBoxState(): { profile: string; guarded: boolean; source: string; warning?: string } {
731
+ const p = readyProfile() as { profile: string; raw: string | null; warning?: string };
732
+ return { profile: p.profile, guarded: p.profile !== "none", source: "AGENT_COORD_READY_PROFILE in this process's env at start (never a message or the bus dir)", ...(p.warning ? { warning: p.warning } : {}) };
733
+ }
734
+
559
735
  export async function capabilitiesTool() {
560
736
  const id = resolveServerIdentity();
561
737
  const report = probeCapabilities({ module: id.path, versionLabel: id.version });
562
738
  try {
563
- return { ...report, transport: await probeTransport(), seats: await seatBuilds() };
739
+ // ⭐ `herdrTail` is RUNTIME truth for this process — the tails actually ticking here — kept apart
740
+ // from the build probe on purpose, because "this build can tail" and "this seat is tailing"
741
+ // are different facts and the first version of #353 reported the first as the second.
742
+ return { ...report, transport: await probeTransport(), herdrTail: herdrTailState(), readyBox: readyBoxState(), seats: await seatBuilds() };
564
743
  } catch (e) {
565
744
  // A THROWN TRANSPORT PROBE IS NOT A BROKEN VERB. An unknown configured value
566
745
  // refuses at startup by design, and this verb is exactly what an operator