agent-coord-mcp 0.17.0 → 0.18.0

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 (52) hide show
  1. package/README.md +34 -5
  2. package/dist/build.js +113 -0
  3. package/dist/build.js.map +1 -0
  4. package/dist/roles.js +92 -0
  5. package/dist/roles.js.map +1 -0
  6. package/dist/server.js +30 -14
  7. package/dist/server.js.map +1 -1
  8. package/dist/store.js +54 -1
  9. package/dist/store.js.map +1 -1
  10. package/dist/tools/admin.js +5 -3
  11. package/dist/tools/admin.js.map +1 -1
  12. package/dist/tools/index.js +2 -0
  13. package/dist/tools/index.js.map +1 -1
  14. package/dist/tools/messaging.js +187 -8
  15. package/dist/tools/messaging.js.map +1 -1
  16. package/dist/tools/registry.js +54 -3
  17. package/dist/tools/registry.js.map +1 -1
  18. package/dist/tools/render.js +84 -0
  19. package/dist/tools/render.js.map +1 -0
  20. package/dist/tools/scopes.js +126 -0
  21. package/dist/tools/scopes.js.map +1 -0
  22. package/dist/tools/shared.js +18 -0
  23. package/dist/tools/shared.js.map +1 -1
  24. package/dist/tools/transport.js +367 -33
  25. package/dist/tools/transport.js.map +1 -1
  26. package/dist/tools/work.js +209 -0
  27. package/dist/tools/work.js.map +1 -0
  28. package/dist/work.js +260 -0
  29. package/dist/work.js.map +1 -0
  30. package/hooks/marker.mjs +18 -0
  31. package/hooks/roles.mjs +79 -0
  32. package/hooks/submit.mjs +271 -0
  33. package/hooks/tier.mjs +104 -11
  34. package/hooks/tmux-pusher.mjs +137 -51
  35. package/package.json +5 -4
  36. package/scripts/check-self-dependency.mjs +42 -0
  37. package/scripts/check-test-count.mjs +83 -0
  38. package/scripts/coord-pusher.mjs +150 -33
  39. package/src/build.ts +111 -0
  40. package/src/roles.ts +111 -0
  41. package/src/server.ts +79 -14
  42. package/src/store.ts +60 -1
  43. package/src/tools/admin.ts +10 -4
  44. package/src/tools/index.ts +2 -0
  45. package/src/tools/messaging.ts +206 -7
  46. package/src/tools/registry.ts +63 -4
  47. package/src/tools/render.ts +80 -0
  48. package/src/tools/scopes.ts +177 -0
  49. package/src/tools/shared.ts +120 -0
  50. package/src/tools/transport.ts +386 -34
  51. package/src/tools/work.ts +265 -0
  52. package/src/work.ts +329 -0
package/hooks/tier.mjs CHANGED
@@ -1,6 +1,6 @@
1
- // Pure delivery-tier logic for the pusher. Dependency-free and
2
- // side-effect-free so it can be unit-tested directly and adds no I/O to the
3
- // delivery hot path.
1
+ // Pure delivery-tier logic for the pusher. Side-effect-free, and its only
2
+ // import (./roles.mjs) is equally pure — so it can be unit-tested directly and
3
+ // adds no I/O to the delivery hot path.
4
4
  //
5
5
  // "urgent" = push now (wakes the target agent's model).
6
6
  // "routine" = queue silently; it rides along as a coalesced digest on the
@@ -9,6 +9,41 @@
9
9
  // All prefix checks are case-sensitive: the protocol prefixes are uppercase
10
10
  // by convention, and a lowercase lookalike is chatter, not a work order.
11
11
 
12
+ import { isGateRunner } from "./roles.mjs";
13
+
14
+ // Typed protocol record → tier (Phase 8). The prefix table below is the same
15
+ // vocabulary parsed out of text; reading the field instead removes the parse
16
+ // entirely, so a body that leads with a greeting no longer downgrades a
17
+ // blocker to routine.
18
+ //
19
+ // TRUST: `record` is caller-supplied, exactly like `from`. A typed record may
20
+ // therefore assert what a text prefix could already assert, and NOTHING more —
21
+ // `scope` resolves the sender against trustedSenders and `done` depends on the
22
+ // RECIPIENT being a gate runner, exactly as the prefix path does. `go` is
23
+ // unconditionally urgent here because literal "GO:" is unconditionally urgent
24
+ // in the prefix path too; do NOT "fix" that asymmetry by gating go, and do not
25
+ // ungate scope to match go — each mirrors its v1 prefix, which is the whole
26
+ // invariant. Typed is not authenticated: anything that would let a peer
27
+ // self-declare urgency belongs in the server-set `urgent` flag, not here.
28
+ function tierFromRecord(rec, m, opts) {
29
+ switch (rec.type) {
30
+ case "blocker":
31
+ case "decision":
32
+ return "urgent";
33
+ case "go":
34
+ return "urgent";
35
+ case "scope":
36
+ return opts.trustedSenders?.has?.(m.from) ? "urgent" : "routine";
37
+ case "done":
38
+ return opts.gateRunner ? "urgent" : "routine";
39
+ // risk / fyi / action / verdict: real semantics, but not a reason to
40
+ // interrupt someone else's turn. A risk that genuinely needs an immediate
41
+ // turn is a blocker — that judgement stays with the sender.
42
+ default:
43
+ return "routine";
44
+ }
45
+ }
46
+
12
47
  export function classifyTier(m, opts = {}) {
13
48
  if (!m) return "routine";
14
49
  // Server-set push-now override (post-/clear reminder). send_message builds
@@ -18,7 +53,39 @@ export function classifyTier(m, opts = {}) {
18
53
  // wants it specifically, and DM volume is tiny next to room traffic. The
19
54
  // tiers exist to absorb broadcast noise, not point-to-point asks (the
20
55
  // liaison relaying a David question must not sit in a digest queue).
21
- if (m.kind === "DM") return "urgent";
56
+ //
57
+ // `tag`, not `kind`: this is the pushers' synthetic channel tag ("DM" /
58
+ // "room #general"), set on their own copy of the message. It used to be
59
+ // called `kind`, which collided with the stored Message.kind (retention
60
+ // weight, "decision"/"status"/"chatter") — a room post tagged
61
+ // kind:"decision" overwrote the channel tag and rendered as `[decision …]`.
62
+ // The tag is process-local and never persisted, so it was the safe half of
63
+ // the collision to rename; Message.kind is on disk in every JSONL file.
64
+ // NOTE: this returns before the record is read, so a DM can never be
65
+ // downgraded by one. The floor rule below makes that moot, but the ordering
66
+ // is load-bearing if anyone reintroduces a record-first branch.
67
+ if (m.tag === "DM") return "urgent";
68
+ // The record is a FLOOR, not an override: it can raise the tier, never lower
69
+ // it. An earlier version let the record win outright, on the argument that
70
+ // Task 3 renders text from the record so the two agree by construction. That
71
+ // is false when BOTH are supplied — contract 3.3 makes the author's `text`
72
+ // win for rendering, so `{text:"BLOCKER: prod down", record:{type:"fyi"}}`
73
+ // displayed a BLOCKER in the pane and delivered it routine, with the reader
74
+ // unable to see why (`record` is never rendered). That is a REGRESSION IN A
75
+ // SAFETY PROPERTY: in v1, "BLOCKER:" at byte 0 always woke the pane. The
76
+ // trigger is ordinary relay, not malice — an aide forwarding a worker's body
77
+ // under its own `fyi` would silently bury that worker's blocker.
78
+ //
79
+ // max() is not the two-overridable-sources design this phase exists to
80
+ // delete: it is monotone, so there is nothing to disagree about, only a
81
+ // higher claim winning. It also fails safe on an UNKNOWN future record type,
82
+ // which hits the routine default — an old pusher meeting a new vocabulary
83
+ // must not bury a "BLOCKER:" body. The one legitimate downgrade, quoting a
84
+ // blocker mid-body, is already handled by the byte-0 rule.
85
+ // Found by ai-workflow-worker-1 gating 721882a.
86
+ if (m.record && typeof m.record.type === "string") {
87
+ if (tierFromRecord(m.record, m, opts) === "urgent") return "urgent";
88
+ }
22
89
  if (typeof m.text !== "string") return "routine";
23
90
  const text = m.text.trimStart();
24
91
  // Control/slash commands are injected raw and must fire immediately.
@@ -46,10 +113,16 @@ export function effectiveTier(m, opts = {}) {
46
113
  }
47
114
 
48
115
  // A gate runner is the agent that consumes DONE: reports (QA / coordinator).
49
- // Resolved from the registry role, with an env override in both directions
50
- // (AGENT_COORD_GATE_RUNNER=1|0).
116
+ // Resolved from the registry role's frozen `roleId` (Phase 8 Task 4) rather
117
+ // than by regex-matching display prose, so renaming the role does not change
118
+ // who runs the gate. Registry entries with no declared roleId fall back to the
119
+ // legacy word match — see roleMatches in roles.mjs. Env override in both
120
+ // directions (AGENT_COORD_GATE_RUNNER=1|0) is applied by the caller.
121
+ //
122
+ // Accepts a plain string or a whole registry entry ({role, roleId}); pass the
123
+ // entry when you have it, or the id is lost.
51
124
  export function isGateRunnerRole(role) {
52
- return /\b(qa|quality|coordinator|gate)\b/i.test(role ?? "");
125
+ return isGateRunner(role);
53
126
  }
54
127
 
55
128
  // In-memory routine queue with the push decision. Ingest classified messages;
@@ -96,15 +169,35 @@ export class TierQueue {
96
169
  // The per-message PARSE CONTRACT line. Agent harnesses read from/room/text
97
170
  // back out of this, so its shape is load-bearing and MUST stay byte-identical
98
171
  // to coord-pusher.mjs's `injectLine`. Compact form (v0.14.0, salvaged from
99
- // v0.8.10): ` [<kind> <HH:MM> <from>] <text>` where kind drops the leading
172
+ // v0.8.10): ` [<tag> <HH:MM> <from>] <text>` where tag drops the leading
100
173
  // "room " ("room #general" → "#general"), the timestamp is HH:MM UTC, and the
101
- // "from=" label is dropped (bare id). kind/time/from never contain spaces
174
+ // "from=" label is dropped (bare id). tag/time/from never contain spaces
102
175
  // (ids are sanitized), so a parser splits on the first "] " unambiguously.
103
176
  export function injectLine(m) {
104
- const tag = String(m.kind ?? "").replace(/^room /, "");
177
+ const tag = String(m.tag ?? "").replace(/^room /, "");
105
178
  const d = new Date(m.ts ?? 0);
106
179
  const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
107
- return ` [${tag} ${hhmm} ${m.from}] ${m.text ?? ""}`;
180
+ let text = m.text ?? "";
181
+ // Phase 8 Task 6: a TYPED record whose rendering spans lines is delivered as
182
+ // ONE attributed line — first line, a count of what was withheld, and the
183
+ // message id as the retrieval handle. Continuation lines used to arrive bare,
184
+ // with no `[tag HH:MM from]` header, so a parser could not attribute them.
185
+ //
186
+ // The handle is the message id, NOT a stashed copy: the full record is
187
+ // already persisted in rooms/<chan>.jsonl or inbox/<id>.jsonl, and
188
+ // retrieve_message reads it back by id (falling through to the append-only
189
+ // archive if compaction moved it). A cache would have had a TTL and lost the
190
+ // record permanently on expiry.
191
+ //
192
+ // Gated on `m.record`: a record-LESS multi-line message is untouched and
193
+ // still arrives unattributed past line 1, exactly as today. Task 6 does not
194
+ // fix hand-typed multi-line messages, and must not change their bytes.
195
+ const nl = text.indexOf("\n");
196
+ if (nl !== -1 && m.record && typeof m.record.type === "string" && m.id) {
197
+ const held = text.split("\n").length - 1;
198
+ text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
199
+ }
200
+ return ` [${tag} ${hhmm} ${m.from}] ${text}`;
108
201
  }
109
202
 
110
203
  // Render one delivery: urgent verbatim under the banner, then at most ONE
@@ -39,11 +39,24 @@
39
39
  * while this daemon runs — both consume the same cursor and would race.
40
40
  *
41
41
  * Caveats:
42
- * - Pasting types into whatever pane state exists. If you're mid-typing in
43
- * the same pane, your buffer gets corrupted. Run the receiving agent in a
44
- * dedicated pane you don't normally edit in.
45
- * - The pusher can't tell if the agent is idle, mid-tool, or showing a
46
- * permission prompt. send-keys is unconditional.
42
+ * - PEER MESSAGES paste into whatever pane state exists. If you're mid-typing
43
+ * in the same pane, your buffer gets corrupted. Run the receiving agent in
44
+ * a dedicated pane you don't normally edit in.
45
+ * - CONTROL COMMANDS (/clear, /compact) are the exception: since they paste
46
+ * RAW, a pane that is busy or holds an unsent draft would either queue them
47
+ * behind a running turn or append them to the draft and ship the result to
48
+ * the model as ordinary text — both observed live, both reporting
49
+ * delivery:"confirmed". So control submission is CONDITIONAL: it waits
50
+ * (bounded) for a busy pane to go idle, refuses to paste onto a draft, and
51
+ * verifies via capture-pane that the command left the input. An unverified
52
+ * submission reports delivery:"pending" with a reason, never "confirmed".
53
+ * See hooks/submit.mjs, shared with scripts/coord-pusher.mjs.
54
+ * - CLEANUP: never `pkill -f tmux-pusher.mjs` — the pattern matches EVERY
55
+ * pusher on the bus, and one agent's test-rig cleanup doing exactly that
56
+ * silently detached all four live agents (2026-07-28). attach_agent puts
57
+ * `--agent <id>` in argv so a pattern kill can be scoped to one agent
58
+ * (`pkill -f "tmux-pusher.mjs --agent <id>"`); better still, use
59
+ * detach_agent or the pid file — both target the exact pid.
47
60
  */
48
61
 
49
62
  import {
@@ -56,10 +69,14 @@ import {
56
69
  unlinkSync,
57
70
  watch,
58
71
  } from "node:fs";
72
+ import { readdirSync, statSync } from "node:fs";
59
73
  import { homedir } from "node:os";
74
+ import { fileURLToPath } from "node:url";
60
75
  import path from "node:path";
61
76
  import { spawn, spawnSync } from "node:child_process";
62
77
  import { effectiveTier, isGateRunnerRole, TierQueue, formatBatch } from "./tier.mjs";
78
+ import { mergeTransportMarker } from "./marker.mjs";
79
+ import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
63
80
 
64
81
  const AGENT_ID = process.env.AGENT_COORD_ID;
65
82
  const TMUX_TARGET = process.env.AGENT_COORD_TMUX_TARGET;
@@ -103,7 +120,9 @@ function refreshTierCtx() {
103
120
  try {
104
121
  const reg = JSON.parse(readFileSync(path.join(ROOT, "agents.json"), "utf8"));
105
122
  for (const [id, entry] of Object.entries(reg ?? {})) {
106
- if (isGateRunnerRole(entry?.role)) trusted.add(id);
123
+ // Pass the whole entry: the frozen roleId lives beside `role`, and
124
+ // reading only the display string would throw the identity away.
125
+ if (isGateRunnerRole(entry)) trusted.add(id);
107
126
  }
108
127
  gateRunner = trusted.has(AGENT_ID);
109
128
  } catch {
@@ -129,6 +148,30 @@ const TRANSPORT_FILE = path.join(ROOT, "transports", `${SAFE_ID}.json`);
129
148
  // entering any agent's context. Mirrors RECEIPTS_DIR in src/store.ts.
130
149
  const RECEIPTS_FILE = path.join(ROOT, "receipts", `${SAFE_ID}.jsonl`);
131
150
  const BUFFER_NAME = `coord-${SAFE_ID}`;
151
+ // Newest mtime across this script AND its sibling hooks modules, sampled once
152
+ // at startup — an upgrade to any of them afterwards leaves this value behind,
153
+ // which is exactly what doctor's stale-pusher-script check compares against.
154
+ // The entry file alone is not enough: control submission lives in submit.mjs
155
+ // and tiering in tier.mjs/roles.mjs, so a fix touching only an import would
156
+ // leave a single-file stamp unchanged and a still-running pusher on the old
157
+ // code would read as fresh. Scanning the whole dir over-covers (a hooks file
158
+ // we never import can flag us stale) — that is the safe direction: a spurious
159
+ // re-attach is loud and cheap, a false green silently invalidates the rollout
160
+ // verification of every pusher-side fix.
161
+ const SCRIPT_MTIME = (() => {
162
+ try {
163
+ const dir = path.dirname(fileURLToPath(import.meta.url));
164
+ let newest;
165
+ for (const f of readdirSync(dir)) {
166
+ if (!f.endsWith(".mjs")) continue;
167
+ const m = statSync(path.join(dir, f)).mtimeMs;
168
+ if (newest === undefined || m > newest) newest = m;
169
+ }
170
+ return newest;
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ })();
132
175
 
133
176
  mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
134
177
  mkdirSync(path.dirname(TRANSPORT_FILE), { recursive: true });
@@ -234,7 +277,15 @@ function collectSource(label, file, cursorKey, cur, staged) {
234
277
  if (fresh.length === 0) return;
235
278
  for (const m of fresh) {
236
279
  if (shouldInject(m)) {
237
- const tagged = { kind: label, ...m };
280
+ // The channel tag lives in `tag`, not `kind`. A stored Message carries
281
+ // its own `kind` ("decision"/"status"/"chatter" — retention weight);
282
+ // sharing the name meant a room post tagged kind:"decision" (what the
283
+ // README recommends for GOs and verdicts) overwrote the channel tag and
284
+ // rendered as `[decision …]` instead of `[#general …]`, breaking
285
+ // injectLine's parse contract. Distinct names remove the collision at
286
+ // the source; assignment still follows the spread so a sender cannot
287
+ // smuggle a `tag` in either.
288
+ const tagged = { ...m, tag: label };
238
289
  // Assigned after the spread so a sender can't smuggle a tier field in.
239
290
  tagged.tier = effectiveTier(tagged, tierCtx);
240
291
  staged.msgs.push(tagged);
@@ -259,7 +310,7 @@ function collectRoomChannel(chan, cur, staged) {
259
310
  if (fresh.length === 0) return;
260
311
  for (const m of fresh) {
261
312
  if (shouldInject(m)) {
262
- const tagged = { kind: `room #${c}`, ...m };
313
+ const tagged = { ...m, tag: `room #${c}` }; // see channel-tag note above
263
314
  tagged.tier = effectiveTier(tagged, tierCtx);
264
315
  staged.msgs.push(tagged);
265
316
  }
@@ -378,8 +429,11 @@ async function injectViaTmux(batch) {
378
429
  for (const m of batch) {
379
430
  if (isControl(m)) {
380
431
  await flushRun();
381
- await pasteAndSubmit(m.text.trim(), false); // validated control: raw slash command
382
- writeReceipts([m]);
432
+ // Control commands are VERIFIED: capture-pane confirms the command
433
+ // actually left the input. A receipt that says "typed" was being read
434
+ // as "ran" — see writeReceipts.
435
+ const outcome = await submitControlCommand(m.text.trim());
436
+ writeReceipts([m], outcome);
383
437
  } else {
384
438
  run.push(m);
385
439
  }
@@ -391,7 +445,12 @@ async function injectViaTmux(batch) {
391
445
  // pane. Receipts are out-of-band proof for the sender (it polls this file),
392
446
  // never injected into any agent's context — so verification costs zero tokens.
393
447
  // Best-effort: a failed receipt write must not break delivery, so we swallow.
394
- function writeReceipts(msgs) {
448
+ // `outcome` (control commands only) carries what verification actually saw.
449
+ // WITHOUT it a receipt proved the pusher TYPED the command, and send_command
450
+ // reported that as delivery:"confirmed" — proof of typing sold as proof of
451
+ // execution. `submitted` is the honest field: false means it is still sitting
452
+ // in the input, and the sender is told so instead of being reassured.
453
+ function writeReceipts(msgs, outcome) {
395
454
  const lines = [];
396
455
  for (const m of msgs) {
397
456
  if (!m || !m.id) continue;
@@ -402,6 +461,13 @@ function writeReceipts(msgs) {
402
461
  ts: Date.now(),
403
462
  from: m.from,
404
463
  control: m.control === true,
464
+ ...(outcome
465
+ ? {
466
+ submitted: outcome.submitted === true,
467
+ verified: outcome.verified === true,
468
+ ...(outcome.reason ? { reason: outcome.reason } : {}),
469
+ }
470
+ : {}),
405
471
  }),
406
472
  );
407
473
  }
@@ -417,45 +483,29 @@ function writeReceipts(msgs) {
417
483
  // a compliant TUI treats the payload as inert data — embedded newlines can't
418
484
  // submit lines or smuggle a "/command". Control commands (/clear, /compact) must
419
485
  // paste RAW (bracketed=false) so the TUI still runs them as slash commands.
486
+ //
487
+ // The pipeline itself lives in ./submit.mjs, shared with scripts/coord-pusher.mjs
488
+ // so the two cannot drift again. This wrapper only supplies tmux.
489
+ const tmuxDeps = {
490
+ target: TMUX_TARGET,
491
+ buffer: BUFFER_NAME,
492
+ run: (args) => spawnSync("tmux", args, { encoding: "utf8" }),
493
+ runStdin: (args, payload) =>
494
+ new Promise((resolve, reject) => {
495
+ const load = spawn("tmux", args);
496
+ load.on("error", reject);
497
+ load.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`tmux ${args[0]} exit ${code}`))));
498
+ load.stdin.end(payload);
499
+ }),
500
+ };
501
+
420
502
  function pasteAndSubmit(payload, bracketed = false) {
421
- return new Promise((resolve, reject) => {
422
- const load = spawn("tmux", ["load-buffer", "-b", BUFFER_NAME, "-"]);
423
- load.on("error", reject);
424
- load.on("exit", (code) => {
425
- if (code !== 0) return reject(new Error(`tmux load-buffer exit ${code}`));
426
- const paste = spawnSync("tmux", [
427
- "paste-buffer",
428
- ...(bracketed ? ["-p"] : []),
429
- "-b",
430
- BUFFER_NAME,
431
- "-t",
432
- TMUX_TARGET,
433
- "-d",
434
- ]);
435
- if (paste.status !== 0) {
436
- return reject(new Error(`tmux paste-buffer: ${(paste.stderr ?? "").toString().trim()}`));
437
- }
438
- // Multi-line paste + Enter is racy: long buffers can still be flushing
439
- // to the target app's input when Enter arrives, and many TUIs (Claude
440
- // Code in particular) treat that first Enter as "still drafting,
441
- // add newline" rather than "submit." Wait briefly so paste settles,
442
- // then send Enter. Some apps additionally need a second Enter to
443
- // exit paste-mode + submit; sending two Enters with a small gap is
444
- // safe (worst case: harmless empty submit ignored by the app). For a
445
- // slash command this also dismisses the autocomplete menu and submits.
446
- setTimeout(() => {
447
- const e1 = spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
448
- if (e1.status !== 0) {
449
- return reject(new Error(`tmux send-keys: ${(e1.stderr ?? "").toString().trim()}`));
450
- }
451
- setTimeout(() => {
452
- spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
453
- resolve();
454
- }, 50);
455
- }, 100);
456
- });
457
- load.stdin.end(payload);
458
- });
503
+ return sharedPasteAndSubmit(tmuxDeps, payload, { bracketed });
504
+ }
505
+
506
+ // Control commands go through the preflight + verify path, never the plain one.
507
+ function submitControlCommand(payload) {
508
+ return sharedSubmitControl(tmuxDeps, payload);
459
509
  }
460
510
 
461
511
  // Publish transport marker so list_agents can show this agent is push-capable.
@@ -470,24 +520,60 @@ const cleanupMarker = () => {
470
520
  // already gone
471
521
  }
472
522
  };
523
+ // Log BEFORE cleaning up. A silent exit that also removes its own marker is
524
+ // indistinguishable from "never attached" — on 2026-07-28 all four pushers on
525
+ // the bus vanished at once and the cause could not be determined afterwards,
526
+ // because every other exit path logs and this one did not. Ruled out at the
527
+ // time: dead-pane self-exit (logs), die() (logs), SIGKILL/OOM (the marker
528
+ // would have survived), a normal exit (the poll intervals are not unref'd),
529
+ // doctor's reaper (has-session returned 0 for every live pane), tests (all
530
+ // isolate AGENT_COORD_DIR), coord-chat (not running), stop-agent.sh (would
531
+ // have killed the session too) and a parent process-group signal (the pusher
532
+ // has its own PGID and SID). That left a signal from outside the repo, with
533
+ // nothing recorded to identify it.
534
+ function logSignal(sig) {
535
+ process.stderr.write(
536
+ `[tmux-pusher] received ${sig} — cleaning up marker for '${AGENT_ID}' (pane ${TMUX_TARGET}, pid ${process.pid}) and exiting\n`,
537
+ );
538
+ }
539
+
473
540
  process.on("SIGINT", () => {
541
+ logSignal("SIGINT");
474
542
  cleanupMarker();
475
543
  process.exit(0);
476
544
  });
477
545
  process.on("SIGTERM", () => {
546
+ logSignal("SIGTERM");
478
547
  cleanupMarker();
479
548
  process.exit(0);
480
549
  });
481
550
  process.on("exit", cleanupMarker);
482
551
 
483
552
  function writeTransportMarker() {
484
- const marker = {
553
+ // Merge over the existing marker, never rebuild from scratch: attach_agent
554
+ // wrote it first and owns fields only the server can know (its
555
+ // serverBuildMtime provenance stamp, and whatever comes next). A
556
+ // from-scratch rewrite here once dropped scriptMtime and silently disabled
557
+ // doctor's stale-pusher-script check — see hooks/marker.mjs.
558
+ let existing;
559
+ try {
560
+ existing = JSON.parse(readFileSync(TRANSPORT_FILE, "utf8"));
561
+ } catch {
562
+ existing = undefined; // first writer, or unreadable — our own fields stand alone
563
+ }
564
+ const marker = mergeTransportMarker(existing, {
485
565
  agentId: AGENT_ID,
486
566
  transport: "tmux-push",
487
567
  pid: process.pid,
488
568
  tmuxTarget: TMUX_TARGET,
489
569
  since: Date.now(),
490
- };
570
+ // MUST be included: attach_agent stamps this too, but this write happens
571
+ // afterwards and would otherwise clobber it — and doctor SKIPS markers
572
+ // without it (pre-v0.8.2 shape), so dropping it silently disabled the
573
+ // stale-pusher-script check for every local pusher. Our own stamp is the
574
+ // more truthful value anyway: it reflects the code THIS process loaded.
575
+ scriptMtime: SCRIPT_MTIME,
576
+ });
491
577
  const tmp = TRANSPORT_FILE + ".tmp";
492
578
  writeFileSync(tmp, JSON.stringify(marker));
493
579
  renameSync(tmp, TRANSPORT_FILE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
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": {
@@ -10,11 +10,12 @@
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",
13
- "prepare": "tsc",
13
+ "prepare": "node scripts/check-self-dependency.mjs && tsc",
14
14
  "start": "node dist/server.js",
15
15
  "dev": "tsx src/server.ts",
16
- "pretest": "tsc",
17
- "test": "node --test \"test/*.test.mjs\""
16
+ "pretest": "node scripts/check-self-dependency.mjs && tsc",
17
+ "test": "node scripts/check-test-count.mjs",
18
+ "test:raw": "node --test \"test/*.test.mjs\""
18
19
  },
19
20
  "files": [
20
21
  "dist",
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Guards against a recurring defect (see docs/QUEUE.md P1): this package's
3
+ // own name has twice been reintroduced into its own dependency graph —
4
+ // once manually, once by an `npm audit fix` run (commit de4e1ee added
5
+ // "agent-coord-mcp": "^0.8.0" back into dependencies while patching
6
+ // fast-uri/hono/ip-address/qs). A self-dependency makes `npm install`
7
+ // resolve against an old published copy of this package instead of the
8
+ // local source, silently breaking dev installs. Run on every `npm install`
9
+ // (via the "prepare" script) so a reintroduction fails loudly and
10
+ // immediately instead of sitting unnoticed until the next audit.
11
+ import { readFileSync } from "node:fs";
12
+
13
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
14
+ const DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
15
+
16
+ function selfDepFields(manifest) {
17
+ return DEP_FIELDS.filter(
18
+ (field) => manifest[field] && Object.prototype.hasOwnProperty.call(manifest[field], pkg.name),
19
+ );
20
+ }
21
+
22
+ const offenders = selfDepFields(pkg);
23
+
24
+ let lockOffender = false;
25
+ try {
26
+ const lock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8"));
27
+ const rootPkg = lock.packages?.[""];
28
+ if (rootPkg && selfDepFields(rootPkg).length) lockOffender = true;
29
+ } catch {
30
+ /* no lockfile yet (fresh checkout pre-install) — nothing to check */
31
+ }
32
+
33
+ if (offenders.length || lockOffender) {
34
+ console.error(
35
+ `[check-self-dependency] "${pkg.name}" depends on itself` +
36
+ (offenders.length ? ` in package.json's ${offenders.join(", ")}` : "") +
37
+ (lockOffender ? `${offenders.length ? " and" : " in"} package-lock.json's root package` : "") +
38
+ `.\nThis has recurred before — a prior "npm audit fix" run reintroduced it (see docs/QUEUE.md P1). ` +
39
+ `Remove the self-reference from both files and re-run "npm install".`,
40
+ );
41
+ process.exit(1);
42
+ }
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // Run the suite and assert it ran the number of tests we expect.
3
+ //
4
+ // WHY: `npm test` was twice observed reporting FOUR fewer tests with zero
5
+ // failures (119→115, 145→141), never reproducible on a re-run. A suite whose
6
+ // count silently varies cannot be used as a gate signal — "all green" and "a
7
+ // file failed to load" look identical. This is the same silent-undercount
8
+ // class as a QUEUE parser returning zero items while passing every test.
9
+ //
10
+ // So the count is an assertion, not a statistic. A short run fails loudly.
11
+ //
12
+ // It compares PASS, not `# tests`. The first version compared `# tests`, and
13
+ // the sighting that followed showed why that was the wrong number: the runner
14
+ // reported `# tests 186 / # pass 182 / # fail 0` — its own totals not adding
15
+ // up, four results landing in no bucket at all. `# tests` read the full 186, so
16
+ // the guard would have passed a run in which four results were lost. The
17
+ // anomaly is a reporter gap, not a short run, and only the pass count sees it.
18
+ //
19
+ // When you add or remove tests, update EXPECTED_TESTS in the same commit —
20
+ // that is the point, not an inconvenience. `AGENT_COORD_EXPECTED_TESTS=n`
21
+ // overrides for a one-off (bisecting, a partial run); `=0` disables the check.
22
+
23
+ import { spawn } from "node:child_process";
24
+
25
+ const EXPECTED_TESTS = 218;
26
+
27
+ const expected = Number(process.env.AGENT_COORD_EXPECTED_TESTS ?? EXPECTED_TESTS);
28
+ // Same glob the suite always used — `--test test/` would recurse differently
29
+ // on some node versions, and a runner that selects a different set of files
30
+ // is exactly the failure this script exists to catch.
31
+ const child = spawn(process.execPath, ["--test", "test/*.test.mjs"], {
32
+ stdio: ["inherit", "pipe", "inherit"],
33
+ shell: false,
34
+ });
35
+
36
+ let out = "";
37
+ child.stdout.on("data", (chunk) => {
38
+ out += chunk;
39
+ process.stdout.write(chunk);
40
+ });
41
+
42
+ child.on("exit", (code, signal) => {
43
+ if (signal) process.exit(1);
44
+ // The TAP summary lines the runner prints once, at the end.
45
+ const num = (label) => {
46
+ const m = out.match(new RegExp(`^# ${label} (\\d+)$`, "m"));
47
+ return m ? Number(m[1]) : null;
48
+ };
49
+ const tests = num("tests");
50
+ const pass = num("pass");
51
+ const fail = num("fail");
52
+
53
+ if (code !== 0 || (fail ?? 0) > 0) process.exit(code || 1);
54
+
55
+ if (expected === 0) {
56
+ console.log(`[check-test-count] count assertion disabled (ran ${tests ?? "?"} tests)`);
57
+ process.exit(0);
58
+ }
59
+ if (pass === null) {
60
+ console.error("[check-test-count] FAIL — no '# pass' summary line in the runner output");
61
+ process.exit(1);
62
+ }
63
+ // The runner's own totals disagreeing is its own finding — report it as such
64
+ // rather than as a count mismatch, so nobody chases a missing test file.
65
+ if (tests !== null && tests !== pass + (fail ?? 0)) {
66
+ console.error(
67
+ `\n[check-test-count] FAIL — the runner's totals do not add up: ${tests} tests, ${pass} pass, ${fail ?? 0} fail.\n` +
68
+ ` ${tests - pass - (fail ?? 0)} result(s) landed in no bucket. This is a reporter gap, not a missing test file.\n`,
69
+ );
70
+ process.exit(1);
71
+ }
72
+ if (pass !== expected) {
73
+ console.error(
74
+ `\n[check-test-count] FAIL — ${pass} tests passed, expected ${expected}.\n` +
75
+ (pass < expected
76
+ ? ` ${expected - pass} result(s) missing. Zero failures does NOT mean green here: a file that fails to load reports nothing.\n` +
77
+ ` Re-run; if the count is stable, a test file is missing or erroring at import.\n`
78
+ : ` ${pass - expected} test(s) were added. Update EXPECTED_TESTS in scripts/check-test-count.mjs in the same commit.\n`),
79
+ );
80
+ process.exit(1);
81
+ }
82
+ console.log(`[check-test-count] ${pass} tests passed, as expected.`);
83
+ });