agent-coord-mcp 0.26.19 → 0.26.20

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 (47) hide show
  1. package/README.md +82 -0
  2. package/dist/capabilities.js +57 -1
  3. package/dist/capabilities.js.map +1 -1
  4. package/dist/server.js +21 -0
  5. package/dist/server.js.map +1 -1
  6. package/dist/tools/records.js +242 -64
  7. package/dist/tools/records.js.map +1 -1
  8. package/dist/tools/registry.js +34 -5
  9. package/dist/tools/registry.js.map +1 -1
  10. package/dist/tools/shared.js.map +1 -1
  11. package/dist/tools/stall.js +2 -1
  12. package/dist/tools/stall.js.map +1 -1
  13. package/dist/tools/transport.js +82 -42
  14. package/dist/tools/transport.js.map +1 -1
  15. package/dist/tools/work.js +95 -3
  16. package/dist/tools/work.js.map +1 -1
  17. package/dist/transports/config.js +82 -0
  18. package/dist/transports/config.js.map +1 -0
  19. package/dist/transports/index.js +113 -0
  20. package/dist/transports/index.js.map +1 -0
  21. package/dist/transports/tmux.js +140 -0
  22. package/dist/transports/tmux.js.map +1 -0
  23. package/dist/transports/types.js +86 -0
  24. package/dist/transports/types.js.map +1 -0
  25. package/hooks/peek-coord.mjs +0 -0
  26. package/hooks/tmux-pusher.mjs +33 -3
  27. package/package.json +14 -11
  28. package/scripts/coord-attention-clock.mjs +0 -0
  29. package/scripts/coord-node.sh +0 -0
  30. package/scripts/coord-stall-clock.mjs +0 -0
  31. package/scripts/coord-token.mjs +0 -0
  32. package/scripts/probe-tmux-liveness.sh +0 -0
  33. package/scripts/spawn-agent.sh +0 -0
  34. package/scripts/stop-agent.sh +0 -0
  35. package/scripts/typed-record-stats.mjs +0 -0
  36. package/src/capabilities.ts +104 -1
  37. package/src/server.ts +21 -0
  38. package/src/tools/records.ts +221 -34
  39. package/src/tools/registry.ts +36 -5
  40. package/src/tools/shared.ts +12 -36
  41. package/src/tools/stall.ts +2 -1
  42. package/src/tools/transport.ts +96 -43
  43. package/src/tools/work.ts +95 -3
  44. package/src/transports/config.ts +110 -0
  45. package/src/transports/index.ts +126 -0
  46. package/src/transports/tmux.ts +177 -0
  47. package/src/transports/types.ts +201 -0
@@ -70,10 +70,85 @@ const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | nu
70
70
  * Returns what it stamped so the caller can report it rather than leaving an
71
71
  * absorbing writer to find it in a diff.
72
72
  */
73
- function writeDoc(repo: string, rel: string, doc: WorkDoc, original: string): { written: boolean; stamped: string[] } {
73
+ /**
74
+ * A WRITE WHOSE BASE MOVED UNDER IT. Thrown, never returned, because a refusal
75
+ * that can be ignored is how the silent loss happened in the first place.
76
+ */
77
+ export class StaleWriteError extends Error {
78
+ constructor(
79
+ readonly rel: string,
80
+ readonly detail: string,
81
+ readonly alreadyWritten: string[],
82
+ ) {
83
+ super(
84
+ `refusing to write ${rel}: it changed on disk after this call read it (${detail}). ` +
85
+ `Nothing was overwritten. Re-read and retry.` +
86
+ (alreadyWritten.length ? ` ALREADY WRITTEN by this call: ${alreadyWritten.join(", ")} — that write stands.` : ""),
87
+ );
88
+ this.name = "StaleWriteError";
89
+ }
90
+ }
91
+
92
+ /** What moved, in terms a caller can act on without diffing the file itself. */
93
+ function describeDrift(original: string, current: string): string {
94
+ const ol = original.split("\n");
95
+ const cl = current.split("\n");
96
+ let i = 0;
97
+ while (i < ol.length && i < cl.length && ol[i] === cl[i]) i++;
98
+ return (
99
+ `${ol.length} → ${cl.length} line(s), ${original.length} → ${current.length} byte(s)` +
100
+ (i < Math.max(ol.length, cl.length) ? `, first difference at line ${i + 1}` : "")
101
+ );
102
+ }
103
+
104
+ /**
105
+ * ⛔ COMPARE-AND-SWAP, AND THE RE-READ IS THE WHOLE POINT.
106
+ *
107
+ * This function used to compare the rendered text against `original` — the
108
+ * caller's IN-MEMORY snapshot from when it read — and then write. It never looked
109
+ * at the file again. Anything written in between was clobbered with no error, no
110
+ * diff, and nothing in the return value: `{ written: true }` came back for a
111
+ * write that had destroyed someone else's row.
112
+ *
113
+ * Measured before fixing: two writers taking the same original and both
114
+ * committing leave TWO rows where three should be, and the first writer's row is
115
+ * simply gone. This is not hypothetical — four processes write these documents
116
+ * (the aide from its own clone, `claim`, `land`, worker seats), and `land --write`
117
+ * ran six times in one afternoon against a queue the aide was editing.
118
+ *
119
+ * So the file is re-read immediately before the write and must still match what
120
+ * the caller read. On a mismatch this REFUSES — loudly, by throwing — and names
121
+ * what moved. A refused write is recoverable; a silent overwrite is not, and the
122
+ * loser of the race never learns it lost.
123
+ *
124
+ * What this does NOT claim: it is not a lock. Two processes can still interleave
125
+ * between this re-read and the `writeFileSync` a few microseconds later. The
126
+ * window goes from "the whole duration of the caller's work" — parsing, git
127
+ * calls, composing a line — down to two adjacent statements. That is a large
128
+ * reduction and not zero, and a lock is the stronger fix if this ever proves
129
+ * insufficient.
130
+ *
131
+ * EXPORTED FOR TESTS, deliberately. The race it guards is BETWEEN PROCESSES —
132
+ * the aide's clone, `claim`, `land` — and `landTool` runs synchronously from its
133
+ * read to its write, so no in-process test can interleave them. An end-to-end
134
+ * attempt passed identically with the guard removed, i.e. it proved nothing. A
135
+ * data-loss guard is worth a narrow export to be testable at the level it lives.
136
+ */
137
+ export function writeDoc(
138
+ repo: string,
139
+ rel: string,
140
+ doc: WorkDoc,
141
+ original: string,
142
+ alreadyWritten: string[] = [],
143
+ ): { written: boolean; stamped: string[] } {
74
144
  const { text, stamped } = renderWorkDocForWrite(doc);
75
145
  if (text === original) return { written: false, stamped: [] };
76
- writeFileSync(path.join(repo, rel), text);
146
+ const p = path.join(repo, rel);
147
+ const current = existsSync(p) ? readFileSync(p, "utf8") : "";
148
+ if (current !== original) {
149
+ throw new StaleWriteError(rel, describeDrift(original, current), alreadyWritten);
150
+ }
151
+ writeFileSync(p, text);
77
152
  return { written: true, stamped };
78
153
  }
79
154
 
@@ -151,6 +226,15 @@ export function summarize(text: string, max = 96): string {
151
226
  * the queue SAYS it waits on this", never "nothing waits on this".
152
227
  */
153
228
  export function noDownstream(items: QueueItem[]): QueueItem[] {
229
+ // ⚠ THE SECOND `!i.done` IN THIS FILE, AND IT IS DELIBERATE — the reconciliation
230
+ // asked for by ⟨q-7d2e04b8⟩. `nextUnblockedTool` passes an ALREADY-FILTERED
231
+ // list, so this re-filter is a no-op on that path; it is kept because this
232
+ // function is EXPORTED and its contract is "given items, which have nothing
233
+ // waiting on them", which must hold for a caller that hands it raw items.
234
+ //
235
+ // The two are not the same predicate and must not be merged: this one is
236
+ // `!done`, while the router's also subtracts the delivery join. Collapsing
237
+ // them would make an exported helper inherit a routing policy.
154
238
  const open = items.filter((i) => !i.done);
155
239
  const named = new Set<string>();
156
240
  for (const i of open) {
@@ -235,43 +319,53 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
235
319
  * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
236
320
  * merely MENTIONS it?
237
321
  *
238
- * `DONE.md` has no status cells, so the board's answer does not transfer and
239
- * this needs its own — and the item's own words for what it needs are that "the
240
- * canon rule must name its citations in a form the join can tell apart".
322
+ * ⛔ THE SIGIL ARM WAS REMOVED ⟨q-4a1e70c5⟩. It answered yes whenever `⟨id⟩`
323
+ * appeared ANYWHERE in an entry, on a census that had inverted: today
324
+ * `docs/DONE.md` carries more sigil mentions than bare ones, because `⟨id⟩` is the
325
+ * house spelling everything else teaches — so the COMPLIANT way to cite an item
326
+ * became the spelling that meant "delivered". Measured before removal, on 143
327
+ * open rows: ELEVEN false positives, ZERO independent true positives (every row
328
+ * it correctly excluded was already excluded by `!i.done`, which runs first), and
329
+ * a MISS on its own founding case — the row recorded as q-b4e7c209 had its work
330
+ * merged under a different row's citation, stayed `[ ]`, and was offered as top
331
+ * P1 twice.
332
+ *
333
+ * ⚠ THE SPELLING RULE DID NOT FAIL BECAUSE THE SPELLING FLIPPED. IT FAILED
334
+ * BECAUSE SPELLING WAS NEVER THE SIGNAL. Entries name item ids for several
335
+ * reasons this repo's own canon REQUIRES — a residual gap must cite its queue
336
+ * item — so no reading of that file separates a closure from a mention. Three
337
+ * replacements were measured and each was worse or equal: position does not
338
+ * discriminate (a known-false and a known-true entry are structurally identical,
339
+ * and only 2 of 252 entries lead with a sigil); `DoneEntry.id` is a derived `d-`
340
+ * id, not the item's; and the citation-slot tie `queue-done-loop` uses gives 23
341
+ * false positives, because open rows cite refs as EVIDENCE.
241
342
  *
242
- * MEASURED ON THE REAL `DONE.md` @17b72ff, which settles which form is which:
243
- * BRACKETED ids appear ZERO times; all ELEVEN id mentions are BARE, in prose,
244
- * inside backticks — every one of them a citation. So:
343
+ * ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
344
+ * CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
345
+ * delivery from prose: it requires the entry to carry the item's own
346
+ * deterministically composed text, `summarize(item.text)`, which a VERB writes.
347
+ * A citation cannot accidentally satisfy it.
245
348
  *
246
- * DELIVERY the entry carries the item's own COMPOSED SUMMARY — the
247
- * deterministic form `land` writes, `summarize(item.text)` — or it
248
- * names the item in the recorded-id sigil `⟨id⟩`, which is this
249
- * corpus's "this IS that item" marker (it is how QUEUE.md records
250
- * identity, and a human writing it in DONE.md means exactly that).
251
- * CITATION a BARE `q-xxxxxxxx` anywhere. This is the form our own shipped
252
- * canon (q-912a67e8) produces: a `DONE` entry may name a residual
253
- * gap but MUST CITE A QUEUE ITEM for it. Obeying that rule wrote an
254
- * open item's id into `DONE.md`, and the substring join read the
255
- * citation as delivery — a canon rule and a code path in direct
256
- * contradiction, where COMPLIANCE with the canon triggered the
257
- * defect. LIVE on origin/main, not latent: the part (a) entry for
258
- * q-507e80c4 cites it exactly this way while part (b) is open work.
349
+ * ⚠ IT IS CURRENTLY UNEXERCISED BY THIS REPO'S CORPUS — 0 of 143 open, 0 of 9
350
+ * closed — AND THAT IS NOT EVIDENCE THAT IT IS DEAD. The cause is practice, not
351
+ * code: rows here are closed with `land --write` and the composed line is then
352
+ * OVERWRITTEN with hand-written prose, so the structural record it would match is
353
+ * destroyed within the minute. **Do not re-derive "dead code" from another zero
354
+ * count.** Its input returns the moment a closing entry keeps what `land` wrote.
259
355
  *
260
- * Note what is deliberately NOT the rule: a shared PR ref. `land` never writes
261
- * the item id into `DONE.md` at all (the summary excludes the `⟨id⟩` token), and
262
- * the live entry for q-507e80c4 cites `#219` while the item's own line cites no
263
- * PR — so a ref-tie would have marked a genuinely-open part (b) as delivered.
356
+ * ⛔ AND WHAT IS NO LONGER GUARDED, because a removal that does not name its own
357
+ * cost is worse than the guard: A ROW WHOSE WORK MERGED UNDER ANOTHER ROW'S
358
+ * CITATION IS ELIGIBLE AGAIN, AND NOTHING IN `next_unblocked` WILL NOTICE.
359
+ * Accepted knowingly — the arm that claimed to cover it did not, on the one live
360
+ * instance it had. Closing such a row is coordinator discipline, not a predicate.
264
361
  */
265
362
  function doneRecordsDelivery(entries: DoneEntry[], id: string, text: string): boolean {
266
363
  const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
267
364
  const target = norm(summarize(text)).replace(/…$/, "");
268
- const sigil = `⟨${id}⟩`;
269
365
  return entries.some((e) => {
270
- const t = String(e.text);
271
- if (t.includes(sigil)) return true;
272
366
  // A leading recorded-id token is stripped before comparing, so an entry
273
367
  // written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
274
- const body = norm(t).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
368
+ const body = norm(String(e.text)).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
275
369
  return target.length >= 12 && body.startsWith(target);
276
370
  });
277
371
  }
@@ -296,6 +390,9 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
296
390
  const q = readDoc(repo, QUEUE_DOC);
297
391
  if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
298
392
  const items = queueItemsOf(q.doc);
393
+ // The seam's own count of unticked rows — the number every axis must add back
394
+ // up to. Taken BEFORE any exclusion so it cannot inherit one.
395
+ const parsedOpen = items.filter((i) => !i.done).length;
299
396
 
300
397
  // ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
301
398
  // establish that.
@@ -313,17 +410,66 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
313
410
  // (Task 21.1) are what make this join reliable — with a content-hash id the
314
411
  // board row and the DONE entry stopped matching the moment anyone reworded
315
412
  // the item, which is how the memory was lost in the first place.
316
- const delivered = new Set<string>();
413
+ // ⛔⛆ `delivered` IS A REASON, NOT AN ERASURE (⟨q-7d2e04b8⟩). This set used to
414
+ // be subtracted from `open` BEFORE any axis ran, so an item it excluded could
415
+ // appear on NO axis by construction — the one guarantee this verb's contract
416
+ // makes is that it never skips silently, and this was the one path that did.
417
+ //
418
+ // MEASURED on the live queue at `a032069`: the file held 143 open rows, the
419
+ // seam parsed all 143, and this verb reported `open: 129`. Its declared axes
420
+ // accounted for ONE. Fourteen rows were outside the router's universe and
421
+ // nothing in the response said so — invisible from the only seat that would
422
+ // notice, because a worker asking for the next item still gets a real one.
423
+ //
424
+ // ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
425
+ // a row is excluded BY a named cause, and the cause is what gets reported.
426
+ const deliveredBy = new Map<string, "board" | "done">();
317
427
  const doneDoc = readDoc(repo, DONE_DOC);
318
428
  const boardDoc = readDoc(repo, BOARD_DOC);
319
429
  const boardText = boardDoc?.text ?? "";
320
430
  const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
321
431
  const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
322
432
  for (const i of items) {
323
- if (boardHoldsItem(boardRows, i.id, String(i.text)) || doneRecordsDelivery(doneEntries, i.id, String(i.text))) delivered.add(i.id);
433
+ if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
434
+ // Board first, and the order is load-bearing for the REPORT rather than the
435
+ // routing: both causes exclude, but a live 🚧 row is a different remedy
436
+ // (wait, or ask its owner) from a landed delivery (close the row).
437
+ if (boardHoldsItem(boardRows, i.id, String(i.text))) deliveredBy.set(i.id, "board");
438
+ else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, "done");
324
439
  }
325
440
 
326
- const open = items.filter((i) => !i.done && !delivered.has(i.id));
441
+ const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
442
+
443
+ // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
444
+ // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
445
+ // reported silently is the same defect as an incorrect one.
446
+ const delivered = items
447
+ .filter((i) => !i.done && deliveredBy.has(i.id))
448
+ .map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id)! }));
449
+
450
+ // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
451
+ // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
452
+ // two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
453
+ // identical row` twice yields `q-c9f3ded8` twice.
454
+ //
455
+ // Everything downstream is keyed by id, so ONE delivery record then excludes
456
+ // BOTH rows: measured on a fixture, a single DONE entry took `parsedOpen: 3`
457
+ // to `offered: 1`. The accounting still reconciles — both are named — so the
458
+ // self-check above CANNOT catch it, which is exactly why it needs its own axis
459
+ // rather than a flag on `reconciles`.
460
+ //
461
+ // ⭐ WHY THIS AXIS AND NOT A COUNT: a MISSING row can always be argued to be a
462
+ // filter working as designed; a row reported TWICE cannot be anything but the
463
+ // accounting. It is the one symptom here that does not rest on a count.
464
+ const idCounts = new Map<string, number>();
465
+ for (const i of items) if (!i.done) idCounts.set(i.id, (idCounts.get(i.id) ?? 0) + 1);
466
+ const duplicateIds = [...idCounts.entries()]
467
+ .filter(([, n]) => n > 1)
468
+ .map(([id, rows]) => ({
469
+ id,
470
+ rows,
471
+ why: `${rows} open rows share the id ${id} — stable ids are derived from row TEXT, so identical rows collide. Every axis here is keyed by id, so one delivery record excludes all ${rows}. Reword one row to separate them.`,
472
+ }));
327
473
  const ranked = open
328
474
  .map((i, idx) => ({ i, idx }))
329
475
  .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
@@ -390,6 +536,26 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
390
536
  ok: true as const,
391
537
  project: args.project,
392
538
  open: open.length,
539
+ // ⛔ THE SELF-CHECK, because the defect this replaces was a NUMBER NOTHING
540
+ // EXPLAINED and the only thing that ever caught it was someone counting the
541
+ // file by hand. `parsedOpen` is every unticked row the seam sees; `offered`
542
+ // is what routing considered; `excluded` is what the axes account for. When
543
+ // `reconciles` is false, rows have left the universe with no named cause —
544
+ // the exact condition that was previously unobservable from the response.
545
+ //
546
+ // ⚠ IT REPORTS RATHER THAN THROWS: a router that refuses to hand out work
547
+ // because its own bookkeeping is off strands every lane, which is worse than
548
+ // the miscount. The caller gets a real item AND the discrepancy.
549
+ accounting: {
550
+ parsedOpen,
551
+ offered: open.length,
552
+ excluded: delivered.length,
553
+ reconciles: parsedOpen === open.length + delivered.length,
554
+ },
555
+ // A NAMED AXIS, not a subtraction. See the comment at `deliveredBy`.
556
+ delivered,
557
+ // A ROW REPORTED TWICE CANNOT BE A FILTER WORKING AS DESIGNED. See above.
558
+ duplicateIds,
393
559
  next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
394
560
  skipped,
395
561
  // A SEPARATE AXIS from `skipped` (blocked) — this is "not this caller's
@@ -406,6 +572,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
406
572
  ...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
407
573
  ...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
408
574
  ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
575
+ ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
576
+ ...delivered.map((d) =>
577
+ d.reason === "board"
578
+ ? `⏭ not offered — already on the board (${d.id})`
579
+ : `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
580
+ ),
409
581
  ],
410
582
  // A SEPARATE AXIS, deliberately. See noDownstream().
411
583
  noDownstream: undiscriminating
@@ -789,9 +961,14 @@ export async function landTool(args: {
789
961
  // an entry added to the record model renders through the pinned glyph contract.
790
962
  const wrote: string[] = [];
791
963
  let stampedNotSupplied: string[] = [];
964
+ // A stale-base refusal is a RESULT, not a crash: the caller needs to know
965
+ // nothing was clobbered, what moved, and what this call had already written
966
+ // before it stopped. Thrown inside writeDoc so it cannot be ignored; converted
967
+ // here so the verb still answers.
968
+ try {
792
969
  if (args.write) {
793
970
  if (queueChanged) {
794
- const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text);
971
+ const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote);
795
972
  if (w.written) wrote.push(QUEUE_DOC);
796
973
  // Rows stamped that this call did not ask to touch. `queueItemId` is the
797
974
  // one item the caller supplied, so anything else here is a row that
@@ -816,8 +993,18 @@ export async function landTool(args: {
816
993
  return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
817
994
  }
818
995
  block.entries.push(entry);
819
- if (writeDoc(repo, DONE_DOC, d.doc, d.text).written) wrote.push(DONE_DOC);
996
+ if (writeDoc(repo, DONE_DOC, d.doc, d.text, wrote).written) wrote.push(DONE_DOC);
997
+ }
998
+ }
999
+ } catch (e) {
1000
+ if (e instanceof StaleWriteError) {
1001
+ return {
1002
+ ok: false as const,
1003
+ error: e.message,
1004
+ staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten },
1005
+ };
820
1006
  }
1007
+ throw e;
821
1008
  }
822
1009
 
823
1010
  // 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
@@ -1,4 +1,5 @@
1
1
  import { detachAgentTool } from "./transport.js";
2
+ import { isLocallyProbeable, isRemoteTmuxKind, targetOf } from "../transports/index.js";
2
3
  import { readAway, secondCoordinatorRefusal } from "./away.js";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { existsSync, openSync, watch } from "node:fs";
@@ -419,6 +420,36 @@ export async function listAgentsTool() {
419
420
  return { agents, evicted, proseOnly };
420
421
  }
421
422
 
423
+ /**
424
+ * EVERY marker on disk, READ-ONLY.
425
+ *
426
+ * `loadLiveTransports` below deletes any marker it judges not-live, which is
427
+ * right for the delivery paths that own that garbage collection and WRONG for a
428
+ * diagnostic: `capabilities` must be able to describe the fleet without changing
429
+ * it. Using the reaping loader from a read-only verb made a mixed-fleet check
430
+ * delete the very marker it was reporting (caught by its own test — the second
431
+ * disagreeing agent vanished between write and read).
432
+ *
433
+ * Liveness is reported per marker rather than filtered on, because a STALE marker
434
+ * naming a different transport is more alarming than a live one, not less: it is
435
+ * a seat that may come back on the wrong transport. Filtering it out would make
436
+ * the remote kind — whose liveness is heartbeat-based and therefore absent
437
+ * without a registry entry — systematically invisible to this check.
438
+ */
439
+ export async function readAllTransportMarkers(): Promise<
440
+ { marker: TransportMarker; live: boolean }[]
441
+ > {
442
+ const out: { marker: TransportMarker; live: boolean }[] = [];
443
+ const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
444
+ const now = Date.now();
445
+ for (const fname of await listTransportFiles()) {
446
+ const marker = await readJson<TransportMarker | null>(path.join(TRANSPORT_DIR, fname), null);
447
+ if (!marker) continue; // unparseable: nothing to attribute, and not ours to delete
448
+ out.push({ marker, live: isMarkerLive(marker, reg, now) });
449
+ }
450
+ return out;
451
+ }
452
+
422
453
  export async function loadLiveTransports(): Promise<Map<string, TransportMarker>> {
423
454
  const out = new Map<string, TransportMarker>();
424
455
  const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
@@ -439,7 +470,7 @@ export async function loadLiveTransports(): Promise<Map<string, TransportMarker>
439
470
  // remote markers (tmux-push-remote, pid 0 on a foreign host) can't be — so we
440
471
  // trust the registry heartbeat the remote pusher refreshes (within STALE_MS).
441
472
  export function isMarkerLive(marker: TransportMarker, reg: AgentRegistry, now: number): boolean {
442
- if (marker.transport === "tmux-push-remote") {
473
+ if (isRemoteTmuxKind(marker.transport)) {
443
474
  const entry = reg[marker.agentId];
444
475
  return !!entry && now - entry.lastHeartbeat < STALE_MS;
445
476
  }
@@ -523,13 +554,13 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
523
554
  if (marker && isMarkerLive(marker, reg, now)) {
524
555
  markerLive = true;
525
556
  reasons.push(
526
- `live ${marker.transport} transport (pid ${marker.pid}${marker.tmuxTarget ? `, pane ${marker.tmuxTarget}` : ""})`,
557
+ `live ${marker.transport} transport (pid ${marker.pid}${targetOf(marker) ? `, pane ${targetOf(marker)}` : ""})`,
527
558
  );
528
559
  if (
529
- marker.transport === "tmux-push" &&
530
- marker.tmuxTarget &&
560
+ isLocallyProbeable(marker.transport) &&
561
+ targetOf(marker) &&
531
562
  process.env.TMUX_PANE &&
532
- marker.tmuxTarget === process.env.TMUX_PANE
563
+ targetOf(marker) === process.env.TMUX_PANE
533
564
  ) {
534
565
  samePane = true;
535
566
  }
@@ -88,42 +88,18 @@ export type AgentEntry = {
88
88
  proseOnly?: { since: number; reason?: string };
89
89
  };
90
90
 
91
- export type TransportMarker = {
92
- agentId: string;
93
- transport: string;
94
- pid: number;
95
- tmuxTarget?: string;
96
- since: number;
97
- // Remote pushers run on a different machine; the local pid is meaningless,
98
- // so we tag the host and use heartbeat-based liveness instead of pidAlive.
99
- host?: string;
100
- // mtime of the pusher script the daemon loaded into memory at spawn time
101
- // (epoch ms). When the on-disk script is upgraded but the daemon isn't
102
- // restarted, doctor() compares this to the current mtime to flag a stale
103
- // pusher — the class of bug that silently dropped /clear /compact in v0.8.1.
104
- // Absent on markers written by older versions (treated as "unknown, skip").
105
- scriptMtime?: number;
106
- // Build identity (newest dist mtime, sampled at that server's module load)
107
- // of the MCP server whose attach_agent stamped this marker. A marker
108
- // stamped by a server predating the current on-disk build was written by
109
- // attach/stamp logic the rebuild replaced — doctor's provenance check flags
110
- // it for a session restart + re-attach. Absent on markers written by older
111
- // versions (treated as "unknown, skip", deliberately mirroring scriptMtime).
112
- serverBuildMtime?: number;
113
- // Does this transport carry ROOM traffic, or DMs only?
114
- //
115
- // A pusher started with `--no-room` delivers inbox messages and nothing
116
- // else, and until this field existed the marker looked identical to a full
117
- // one — so `status` said `attached: true` and an agent sat with its room
118
- // feed off while every reading said healthy. That is how worker-2 missed
119
- // its channel traffic.
120
- //
121
- // ABSENT MEANS UNKNOWN, NEVER "ON" — deliberately mirroring scriptMtime and
122
- // serverBuildMtime above. A marker from an older pusher cannot tell us, and
123
- // reporting an unasked question as full capability is the defect this field
124
- // exists to remove.
125
- rooms?: boolean;
126
- };
91
+ /**
92
+ * THE MARKER TYPE NOW LIVES IN THE SEAM, and is re-exported here so the dozens
93
+ * of `from "./shared.js"` imports keep working.
94
+ *
95
+ * It was defined here, and the transport extraction gave it a second definition
96
+ * in `transports/types.ts` — two structurally-similar types that a consumer
97
+ * could satisfy while missing a field, which is the duplication this task exists
98
+ * to remove rather than double. One definition, one home: the transport layer,
99
+ * which is what the field describes. The nine fields and their comments moved
100
+ * with it verbatim, including `target`'s dual-write rule.
101
+ */
102
+ export type { TransportMarker } from "../transports/types.js";
127
103
 
128
104
  /**
129
105
  * How to REPORT a transport's room capability.
@@ -15,6 +15,7 @@
15
15
  * from "nothing ran". A check that only speaks when it fires cannot be told from
16
16
  * a broken one.
17
17
  */
18
+ import { isLocallyProbeable } from "../transports/index.js";
18
19
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
19
20
  import { execFileSync } from "node:child_process";
20
21
  import path from "node:path";
@@ -311,7 +312,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
311
312
  // agent with no transport, or a REMOTE one where the heartbeat genuinely
312
313
  // is the liveness mechanism, still HITs on a dead heartbeat.
313
314
  const marker = liveTransports.get(agentId);
314
- if (age > limit && marker && marker.transport === "tmux-push") {
315
+ if (age > limit && marker && isLocallyProbeable(marker.transport)) {
315
316
  unmeasurable.push({
316
317
  agentId,
317
318
  value: marker.transport,