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.
- package/README.md +12 -2
- package/dist/build.js.map +1 -1
- package/dist/roles.js +10 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +164 -42
- package/dist/server.js.map +1 -1
- package/dist/store.js +33 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +24 -5
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/registry.js +69 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render.js +1 -83
- package/dist/tools/render.js.map +1 -1
- package/dist/tools/shared.js +1 -3
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +97 -7
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +98 -24
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +1 -259
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/roles.mjs +12 -0
- package/hooks/tier.mjs +9 -4
- package/hooks/tmux-pusher.mjs +16 -5
- package/package.json +16 -16
- package/scripts/check-self-dependency.mjs +62 -14
- package/scripts/check-test-count.mjs +3 -3
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-pusher.mjs +39 -9
- package/scripts/coord-token.mjs +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +181 -47
- package/src/store.ts +45 -1
- package/src/tools/admin.ts +22 -5
- package/src/tools/registry.ts +96 -0
- package/src/tools/render.ts +1 -80
- package/src/tools/shared.ts +18 -77
- package/src/tools/transport.ts +116 -6
- package/src/tools/work.ts +118 -30
- package/src/work.ts +31 -329
- package/dist/tools.js +0 -1852
- package/dist/tools.js.map +0 -1
package/hooks/tmux-pusher.mjs
CHANGED
|
@@ -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
|
-
|
|
135
|
-
|
|
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.
|
|
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
|
|
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": "^
|
|
52
|
+
"@types/node": "^26.2.0",
|
|
61
53
|
"@types/proper-lockfile": "^4.1.4",
|
|
62
|
-
"tsx": "^4.
|
|
63
|
-
"typescript": "^
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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(
|
|
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");
|
package/scripts/coord-node.sh
CHANGED
|
File without changes
|
package/scripts/coord-pusher.mjs
CHANGED
|
@@ -114,15 +114,41 @@ try {
|
|
|
114
114
|
} catch (e) {
|
|
115
115
|
die(`register failed: ${e?.message ?? e}`);
|
|
116
116
|
}
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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,
|
package/scripts/coord-token.mjs
CHANGED
|
File without changes
|
package/scripts/spawn-agent.sh
CHANGED
|
File without changes
|
package/scripts/stop-agent.sh
CHANGED
|
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`
|