agent-coord-mcp 0.19.0 → 0.23.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 (46) hide show
  1. package/README.md +12 -2
  2. package/dist/build.js.map +1 -1
  3. package/dist/roles.js +10 -0
  4. package/dist/roles.js.map +1 -1
  5. package/dist/server.js +164 -42
  6. package/dist/server.js.map +1 -1
  7. package/dist/store.js +33 -1
  8. package/dist/store.js.map +1 -1
  9. package/dist/tools/admin.js +24 -5
  10. package/dist/tools/admin.js.map +1 -1
  11. package/dist/tools/registry.js +69 -1
  12. package/dist/tools/registry.js.map +1 -1
  13. package/dist/tools/render.js +1 -83
  14. package/dist/tools/render.js.map +1 -1
  15. package/dist/tools/shared.js +1 -3
  16. package/dist/tools/shared.js.map +1 -1
  17. package/dist/tools/transport.js +97 -7
  18. package/dist/tools/transport.js.map +1 -1
  19. package/dist/tools/work.js +98 -24
  20. package/dist/tools/work.js.map +1 -1
  21. package/dist/work.js +1 -259
  22. package/dist/work.js.map +1 -1
  23. package/hooks/peek-coord.mjs +0 -0
  24. package/hooks/roles.mjs +12 -0
  25. package/hooks/tier.mjs +9 -4
  26. package/hooks/tmux-pusher.mjs +16 -5
  27. package/package.json +16 -16
  28. package/scripts/check-self-dependency.mjs +62 -14
  29. package/scripts/check-test-count.mjs +3 -3
  30. package/scripts/coord-node.sh +0 -0
  31. package/scripts/coord-pusher.mjs +39 -9
  32. package/scripts/coord-token.mjs +0 -0
  33. package/scripts/spawn-agent.sh +0 -0
  34. package/scripts/stop-agent.sh +0 -0
  35. package/src/roles.ts +12 -0
  36. package/src/server.ts +181 -47
  37. package/src/store.ts +45 -1
  38. package/src/tools/admin.ts +22 -5
  39. package/src/tools/registry.ts +96 -0
  40. package/src/tools/render.ts +1 -80
  41. package/src/tools/shared.ts +18 -77
  42. package/src/tools/transport.ts +116 -6
  43. package/src/tools/work.ts +118 -30
  44. package/src/work.ts +31 -329
  45. package/dist/tools.js +0 -1852
  46. package/dist/tools.js.map +0 -1
@@ -75,6 +75,7 @@ import { fileURLToPath } from "node:url";
75
75
  import path from "node:path";
76
76
  import { spawn, spawnSync } from "node:child_process";
77
77
  import { effectiveTier, isGateRunnerRole, TierQueue, formatBatch } from "./tier.mjs";
78
+ import { isCi } from "./roles.mjs";
78
79
  import { mergeTransportMarker } from "./marker.mjs";
79
80
  import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
80
81
 
@@ -113,9 +114,10 @@ const MAX_QUEUE_MS = parseInt(process.env.AGENT_COORD_MAX_QUEUE_MS || "15000", 1
113
114
  // SCOPE: changes are honored only from these trusted ids. Re-resolved from
114
115
  // the registry every 30s so a role change doesn't strand a stale pusher;
115
116
  // AGENT_COORD_GATE_RUNNER=1|0 overrides in both directions.
116
- let tierCtx = { enabled: TIERS_ENABLED, gateRunner: false, trustedSenders: new Set() };
117
+ let tierCtx = { enabled: TIERS_ENABLED, gateRunner: false, ci: false, trustedSenders: new Set() };
117
118
  function refreshTierCtx() {
118
119
  let gateRunner = false;
120
+ let ci = false;
119
121
  const trusted = new Set();
120
122
  try {
121
123
  const reg = JSON.parse(readFileSync(path.join(ROOT, "agents.json"), "utf8"));
@@ -123,18 +125,21 @@ function refreshTierCtx() {
123
125
  // Pass the whole entry: the frozen roleId lives beside `role`, and
124
126
  // reading only the display string would throw the identity away.
125
127
  if (isGateRunnerRole(entry)) trusted.add(id);
128
+ if (id === AGENT_ID) ci = isCi(entry);
126
129
  }
127
130
  gateRunner = trusted.has(AGENT_ID);
128
131
  } catch {
129
- // No registry yet — conservative default (not a gate runner).
132
+ // No registry yet — conservative default (not a gate runner, not ci).
130
133
  }
131
134
  const env = process.env.AGENT_COORD_GATE_RUNNER;
132
135
  if (env === "1" || env === "true") gateRunner = true;
133
136
  if (env === "0" || env === "false") gateRunner = false;
134
- if (gateRunner !== tierCtx.gateRunner) {
135
- process.stderr.write(`[tmux-pusher] gate-runner resolved: ${gateRunner}\n`);
137
+ // AGENT_COORD_GATE_RUNNER must not flip ci — that would look like a
138
+ // verdict grant. Express-lane wake is roleId === ci only.
139
+ if (gateRunner !== tierCtx.gateRunner || ci !== tierCtx.ci) {
140
+ process.stderr.write(`[tmux-pusher] gate-runner resolved: ${gateRunner} ci: ${ci}\n`);
136
141
  }
137
- tierCtx = { enabled: TIERS_ENABLED, gateRunner, trustedSenders: trusted };
142
+ tierCtx = { enabled: TIERS_ENABLED, gateRunner, ci, trustedSenders: trusted };
138
143
  }
139
144
  refreshTierCtx();
140
145
  setInterval(refreshTierCtx, 30_000).unref();
@@ -461,6 +466,12 @@ function writeReceipts(msgs, outcome) {
461
466
  ts: Date.now(),
462
467
  from: m.from,
463
468
  control: m.control === true,
469
+ // Build identity of the pusher that did the typing/verifying — the
470
+ // same module-graph stamp the transport marker carries (SCRIPT_MTIME
471
+ // covers hooks/*.mjs, not just this file). Lets deliveryOutcome note
472
+ // a "confirmed" issued by pre-upgrade verification logic. Omitted
473
+ // when unknown: absence reads as UNKNOWN downstream, never as fresh.
474
+ ...(SCRIPT_MTIME !== undefined ? { scriptMtime: SCRIPT_MTIME } : {}),
464
475
  ...(outcome
465
476
  ? {
466
477
  submitted: outcome.submitted === true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.19.0",
3
+ "version": "0.23.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": {
@@ -8,15 +8,6 @@
8
8
  "coord-chat": "scripts/coord-chat.mjs",
9
9
  "coord-pusher": "scripts/coord-pusher.mjs"
10
10
  },
11
- "scripts": {
12
- "build": "tsc",
13
- "prepare": "node scripts/check-self-dependency.mjs && tsc",
14
- "start": "node dist/server.js",
15
- "dev": "tsx src/server.ts",
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\""
19
- },
20
11
  "files": [
21
12
  "dist",
22
13
  "src",
@@ -52,14 +43,23 @@
52
43
  "node": ">=18"
53
44
  },
54
45
  "dependencies": {
55
- "@modelcontextprotocol/sdk": "^1.0.4",
46
+ "@modelcontextprotocol/sdk": "^1.30.0",
56
47
  "proper-lockfile": "^4.1.2",
57
- "zod": "^4.4.3"
48
+ "zod": "^4.4.3",
49
+ "@davidbalzan/groundwork-seam": "0.1.0"
58
50
  },
59
51
  "devDependencies": {
60
- "@types/node": "^25.9.2",
52
+ "@types/node": "^26.2.0",
61
53
  "@types/proper-lockfile": "^4.1.4",
62
- "tsx": "^4.19.2",
63
- "typescript": "^6.0.3"
54
+ "tsx": "^4.23.12",
55
+ "typescript": "^7.0.2"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc",
59
+ "start": "node dist/server.js",
60
+ "dev": "tsx src/server.ts",
61
+ "pretest": "node scripts/check-self-dependency.mjs && tsc",
62
+ "test": "node scripts/check-test-count.mjs",
63
+ "test:raw": "node --test \"test/*.test.mjs\""
64
64
  }
65
- }
65
+ }
@@ -17,10 +17,13 @@
17
17
  // was untouched; a reviewer reads the claim, not the diff. This guard is
18
18
  // cheap insurance against that class, not evidence of a recurring bug.
19
19
  //
20
- // Runs on `prepare` (every install), `pretest`, and `prepublishOnly`.
21
- import { readFileSync } from "node:fs";
20
+ // Runs on `prepare` (every install) and `pretest`.
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { dirname, join, relative } from "node:path";
23
+ import { fileURLToPath } from "node:url";
22
24
 
23
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
25
+ const pkgDir = fileURLToPath(new URL("..", import.meta.url));
26
+ const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
24
27
  const DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
25
28
 
26
29
  function selfDepFields(manifest) {
@@ -29,26 +32,71 @@ function selfDepFields(manifest) {
29
32
  );
30
33
  }
31
34
 
32
- const offenders = selfDepFields(pkg);
35
+ function walkFor(name, start) {
36
+ let dir = start;
37
+ for (let i = 0; i < 6; i++) {
38
+ const candidate = join(dir, name);
39
+ if (existsSync(candidate)) return { file: candidate, root: dir };
40
+ const parent = dirname(dir);
41
+ if (parent === dir) break;
42
+ dir = parent;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function npmLockSelfDep() {
48
+ const sibling = join(pkgDir, "package-lock.json");
49
+ if (!existsSync(sibling)) return false;
50
+ try {
51
+ const lock = JSON.parse(readFileSync(sibling, "utf8"));
52
+ const rootPkg = lock.packages?.[""];
53
+ return Boolean(rootPkg && selfDepFields(rootPkg).length);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
33
58
 
34
- let lockOffender = false;
35
- try {
36
- const lock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8"));
37
- const rootPkg = lock.packages?.[""];
38
- if (rootPkg && selfDepFields(rootPkg).length) lockOffender = true;
39
- } catch {
40
- /* no lockfile yet (fresh checkout pre-install) — nothing to check */
59
+ // pnpm-lock.yaml has no JSON root package. Check this package's importer
60
+ // block for a key named after the package (a self-dep).
61
+ function pnpmLockSelfDep() {
62
+ const found = walkFor("pnpm-lock.yaml", pkgDir);
63
+ if (!found) return false;
64
+ let text;
65
+ try {
66
+ text = readFileSync(found.file, "utf8");
67
+ } catch {
68
+ return false;
69
+ }
70
+ const rel = relative(found.root, pkgDir).split("\\").join("/") || ".";
71
+ const key = rel === "." ? "." : rel;
72
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
73
+ const header = new RegExp(`(?<=^|\\n) ${escaped}:\\n`);
74
+ const start = text.search(header);
75
+ if (start === -1) return false;
76
+ const from = text.indexOf("\n", start) + 1;
77
+ const next = text.slice(from).search(/\n [^ \n]/);
78
+ const block = next === -1 ? text.slice(from) : text.slice(from, from + next);
79
+ const selfKey = new RegExp(`^[ \\t]+${pkg.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*$`, "m");
80
+ return selfKey.test(block);
41
81
  }
42
82
 
43
- if (offenders.length || lockOffender) {
83
+ const offenders = selfDepFields(pkg);
84
+ const npmLockOffender = npmLockSelfDep();
85
+ const pnpmLockOffender = pnpmLockSelfDep();
86
+
87
+ if (offenders.length || npmLockOffender || pnpmLockOffender) {
88
+ const lockBits = [
89
+ npmLockOffender && "package-lock.json's root package",
90
+ pnpmLockOffender && "pnpm-lock.yaml's importer for this package",
91
+ ].filter(Boolean);
44
92
  console.error(
45
93
  `[check-self-dependency] "${pkg.name}" depends on itself` +
46
94
  (offenders.length ? ` in package.json's ${offenders.join(", ")}` : "") +
47
- (lockOffender ? `${offenders.length ? " and" : " in"} package-lock.json's root package` : "") +
95
+ (lockBits.length ? `${offenders.length ? " and" : " in"} ${lockBits.join(" and ")}` : "") +
48
96
  `.\nThis happened once before, added by an "npm audit fix" whose commit message said it was ` +
49
97
  `lockfile-only (de4e1ee, removed in 3bce3ce — see docs/DONE.md). If you just ran an audit or ` +
50
98
  `dependency update, check what else it changed in package.json. ` +
51
- `Remove the self-reference from both files and re-run "npm install".`,
99
+ `Remove the self-reference from the manifest and lockfile.`,
52
100
  );
53
101
  process.exit(1);
54
102
  }
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { spawn } from "node:child_process";
24
24
 
25
- const EXPECTED_TESTS = 226;
25
+ const EXPECTED_TESTS = 272;
26
26
 
27
27
  const expected = Number(process.env.AGENT_COORD_EXPECTED_TESTS ?? EXPECTED_TESTS);
28
28
  // Same glob the suite always used — `--test test/` would recurse differently
@@ -41,9 +41,9 @@ child.stdout.on("data", (chunk) => {
41
41
 
42
42
  child.on("exit", (code, signal) => {
43
43
  if (signal) process.exit(1);
44
- // The TAP summary lines the runner prints once, at the end.
44
+ // TAP (`# pass N`) on Node 20–22; spec reporter (`ℹ pass N`) on newer Node.
45
45
  const num = (label) => {
46
- const m = out.match(new RegExp(`^# ${label} (\\d+)$`, "m"));
46
+ const m = out.match(new RegExp(`^(?:#|ℹ) ${label} (\\d+)\\s*$`, "m"));
47
47
  return m ? Number(m[1]) : null;
48
48
  };
49
49
  const tests = num("tests");
File without changes
@@ -114,15 +114,41 @@ try {
114
114
  } catch (e) {
115
115
  die(`register failed: ${e?.message ?? e}`);
116
116
  }
117
- // Stamp the mtime of THIS process's loaded script so doctor() can spot a
118
- // stale remote pusher after a remote-side upgrade (see v0.8.2 stale-pusher
119
- // check — same hazard as the local tmux-pusher).
120
- let scriptMtime;
121
- try {
122
- const { statSync } = await import("node:fs");
123
- const { fileURLToPath } = await import("node:url");
124
- scriptMtime = statSync(fileURLToPath(import.meta.url)).mtimeMs;
125
- } catch { /* non-fatal */ }
117
+ // Build identity of THIS pusher process: newest mtime across the entry file
118
+ // AND its ../hooks/*.mjs imports, sampled once at startup. The entry file
119
+ // alone is not enough — the paste/submit pipeline lives in hooks/submit.mjs,
120
+ // so a fix landing there alone would leave a single-file stamp unchanged and
121
+ // a still-running pusher on the old code would read as fresh (#28's lesson,
122
+ // carried across the wire). Over-covering hooks siblings we don't import is
123
+ // the safe direction: a spurious stale flag is loud and cheap, a false fresh
124
+ // silently invalidates rollout verification. Stamped on the transport marker
125
+ // AND on every receipt this process reports; undefined on failure — an
126
+ // absent stamp reads as UNKNOWN downstream, never as fresh, and a partial
127
+ // (entry-only) value could read fresh while submit.mjs is exactly the stale
128
+ // part.
129
+ const scriptMtime = await (async () => {
130
+ try {
131
+ const { statSync, readdirSync } = await import("node:fs");
132
+ const { fileURLToPath } = await import("node:url");
133
+ const path = await import("node:path");
134
+ const self = fileURLToPath(import.meta.url);
135
+ let newest = statSync(self).mtimeMs;
136
+ const hooksDir = path.join(path.dirname(self), "..", "hooks");
137
+ for (const f of readdirSync(hooksDir)) {
138
+ if (!f.endsWith(".mjs")) continue;
139
+ let m;
140
+ try {
141
+ m = statSync(path.join(hooksDir, f)).mtimeMs;
142
+ } catch {
143
+ continue; // deleted mid-scan
144
+ }
145
+ if (m > newest) newest = m;
146
+ }
147
+ return newest;
148
+ } catch {
149
+ return undefined; // non-fatal — absence stays honest
150
+ }
151
+ })();
126
152
  try {
127
153
  await call(
128
154
  "report_transport",
@@ -323,6 +349,10 @@ async function reportReceipts(msgs, outcome) {
323
349
  id: m.id,
324
350
  ...(m.from !== undefined ? { from: m.from } : {}),
325
351
  control: m.control === true,
352
+ // Our build identity (module-graph mtime, see the startup stamp) —
353
+ // ties this receipt to the code that typed/verified the delivery.
354
+ // Omitted when unknown; the server must not default it.
355
+ ...(scriptMtime !== undefined ? { scriptMtime } : {}),
326
356
  ...(outcome
327
357
  ? {
328
358
  submitted: outcome.submitted === true,
File without changes
File without changes
File without changes
package/src/roles.ts CHANGED
@@ -33,6 +33,10 @@ export function slugifyRole(role: unknown): string {
33
33
 
34
34
  export const GATE_RUNNER_ROLE_IDS = new Set(["qa", "quality", "coordinator", "gate"]);
35
35
  export const COORDINATOR_ROLE_IDS = new Set(["coordinator"]);
36
+ // CI receives express-lane DONE: wake. Not a gate-runner — must not grant verdict.
37
+ export const CI_ROLE_IDS = new Set(["ci"]);
38
+ // Console / watchers. May send prefixed messages. Not a gate-runner.
39
+ export const AUTOMATION_ROLE_IDS = new Set(["automation"]);
36
40
 
37
41
  export function resolveRole(role: RoleInput): ResolvedRole | undefined {
38
42
  if (role === null || role === undefined) return undefined;
@@ -70,6 +74,14 @@ export function isCoordinator(role: RoleInput): boolean {
70
74
  return roleMatches(role, COORDINATOR_ROLE_IDS);
71
75
  }
72
76
 
77
+ export function isCi(role: RoleInput): boolean {
78
+ return roleMatches(role, CI_ROLE_IDS);
79
+ }
80
+
81
+ export function isAutomation(role: RoleInput): boolean {
82
+ return roleMatches(role, AUTOMATION_ROLE_IDS);
83
+ }
84
+
73
85
  // Which roles may emit which record.type at the send path. Enforcement lives
74
86
  // in messaging.ts (checkRecordAuthority); the table lives here so register/join
75
87
  // can ECHO the consequence back at onboarding — a role that cannot emit `go`