agent-coord-mcp 0.19.1 → 0.24.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.
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.19.1",
3
+ "version": "0.24.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.1"
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 = 262;
25
+ const EXPECTED_TESTS = 279;
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
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`
package/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
7
  import { unlinkSync, writeFileSync } from "node:fs";
8
+ import { z, type ZodRawShape } from "zod";
8
9
  import {
9
10
  ensureDirs,
10
11
  getTokenMap,
@@ -240,10 +241,21 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
240
241
 
241
242
  const server = new McpServer({
242
243
  name: "agent-coord",
243
- version: "0.1.0",
244
+ version: "0.24.0",
244
245
  });
245
246
 
246
- server.tool(
247
+ const addTool = (
248
+ name: string,
249
+ description: string,
250
+ inputSchema: ZodRawShape,
251
+ cb: (args: Record<string, unknown>) => Promise<ReturnType<typeof jsonResult>>,
252
+ ) => {
253
+ server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) =>
254
+ cb((args ?? {}) as Record<string, unknown>),
255
+ );
256
+ };
257
+
258
+ addTool(
247
259
  "join",
248
260
  "Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for.",
249
261
  joinSchema,
@@ -266,147 +278,147 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
266
278
  },
267
279
  );
268
280
 
269
- server.tool(
281
+ addTool(
270
282
  "register",
271
283
  "Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control.",
272
284
  registerSchema,
273
285
  gate("agentId", registerTool as (a: Record<string, unknown>) => Promise<unknown>),
274
286
  );
275
287
 
276
- server.tool(
288
+ addTool(
277
289
  "unregister",
278
290
  "Tear down this agent: detach any attached transport (kills the pusher) and remove the registry entry. Clean shutdown counterpart to `join`.",
279
291
  unregisterSchema,
280
292
  gate("agentId", unregisterTool as (a: Record<string, unknown>) => Promise<unknown>),
281
293
  );
282
294
 
283
- server.tool(
295
+ addTool(
284
296
  "quit",
285
297
  "Clean shutdown: unregister this agent (detach transport, leave rooms, remove registry entry) then exit the MCP process. Only callable by the session's bound identity. Use this to cleanly hand off before a restart with a new name.",
286
298
  quitSchema,
287
299
  gate("agentId", quitTool as unknown as (a: Record<string, unknown>) => Promise<unknown>),
288
300
  );
289
301
 
290
- server.tool(
302
+ addTool(
291
303
  "status",
292
304
  "Introspect this agent's coord state: registration, attached transport, inbox depth and unread count, and whether this MCP server is running inside tmux. Useful for debugging 'why isn't my DM landing'. Read-only — naming an agentId here never binds this session's identity.",
293
305
  statusSchema,
294
306
  gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
295
307
  );
296
308
 
297
- server.tool(
309
+ addTool(
298
310
  "heartbeat",
299
311
  "Refresh this agent's lastHeartbeat timestamp.",
300
312
  heartbeatSchema,
301
313
  gate("agentId", heartbeatTool as (a: Record<string, unknown>) => Promise<unknown>),
302
314
  );
303
315
 
304
- server.tool(
316
+ addTool(
305
317
  "ping",
306
318
  "Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat or live transport), reachable (a DM pushed now would land), granular checks, and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity, but read-only — naming 'from' here never binds this session's identity.",
307
319
  pingSchema,
308
320
  gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
309
321
  );
310
322
 
311
- server.tool(
323
+ addTool(
312
324
  "list_agents",
313
325
  "List all known agents and whether they appear online (heartbeat <5min).",
314
326
  listAgentsSchema,
315
327
  gate(null, listAgentsTool as () => Promise<unknown>),
316
328
  );
317
329
 
318
- server.tool(
330
+ addTool(
319
331
  "send_message",
320
- "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. The 'from' field is enforced against the session's bound identity when binding is configured.",
332
+ "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured.",
321
333
  sendMessageSchema,
322
334
  gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
323
335
  );
324
336
 
325
- server.tool(
337
+ addTool(
326
338
  "send_command",
327
339
  "Inject a context-management slash command (/clear or /compact) directly into a sub-agent's live tmux session — delivered RAW with no banner or prefix, so the agent's CLI runs it as a real slash command. Target one agent with 'to' or broadcast to a channel's tmux-attached members with 'room' (never the sender). Hard-gated to tmux: returns ok:false if the target has no live tmux-push(-remote) transport. By default BLOCKS until the receiving pusher confirms it actually typed the command into the pane (out-of-band delivery receipt, no added agent context) and returns delivery:'confirmed' with deliveredAt, or delivery:'pending'+warning if no receipt arrived within deliveryTimeoutMs (default 8000) — a stale/wedged pusher. Pass waitForDelivery:false for fire-and-forget. Intended for a lead agent to clear/compact sub-agent context and save tokens. The command allowlist is locked to /clear and /compact; nothing else is accepted. 'from' is enforced against the session's bound identity.",
328
340
  sendCommandSchema,
329
341
  gate("from", sendCommandTool as (a: Record<string, unknown>) => Promise<unknown>),
330
342
  );
331
343
 
332
- server.tool(
344
+ addTool(
333
345
  "read_messages",
334
346
  "Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Room and status reads return the most recent 50 entries per call — pass limit to override (max 500). When the backlog exceeds the window, the older overflow is replaced by a compact `history` digest carrying a retrieval hash; call retrieve_room_history(hash) to expand it. Inbox drains fully by default. Advances the per-channel cursor unless peek=true.",
335
347
  readMessagesSchema,
336
348
  gate("agentId", readMessagesTool as (a: Record<string, unknown>) => Promise<unknown>),
337
349
  );
338
350
 
339
- server.tool(
351
+ addTool(
340
352
  "retrieve_room_history",
341
353
  "Expand a compressed channel-history digest returned by read_messages. Pass the `hash` from the `history` field; optionally pass `query` to return only matching messages (case-insensitive substring). Entries are scoped to the agent that produced them and expire after 30 minutes — if expired, re-read the channel with a higher limit instead.",
342
354
  retrieveRoomHistorySchema,
343
355
  gate("agentId", retrieveRoomHistoryTool as (a: Record<string, unknown>) => Promise<unknown>),
344
356
  );
345
357
 
346
- server.tool(
358
+ addTool(
347
359
  "retrieve_message",
348
360
  "Expand a `retrieve_message id=<uuid>` handle from a pane digest into the full message and its typed `record`. A record whose text rendering spans multiple lines (a DAVID_DECISION packet) is delivered to a pane as ONE attributed line plus this handle; call it to get the structured record back. Reads the message by id from the channels you can read (your inbox and rooms you belong to), falling through to the append-only archive if compaction moved it — so unlike retrieve_room_history there is no TTL and nothing to expire. A handle for a message never delivered to you is simply not found.",
349
361
  retrieveMessageSchema,
350
362
  gate("agentId", retrieveMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
351
363
  );
352
364
 
353
- server.tool(
365
+ addTool(
354
366
  "post_status",
355
367
  "Append a status broadcast to the shared status stream.",
356
368
  postStatusSchema,
357
369
  gate("agentId", postStatusTool as (a: Record<string, unknown>) => Promise<unknown>),
358
370
  );
359
371
 
360
- server.tool(
372
+ addTool(
361
373
  "prune",
362
374
  "Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7); kind='decision' posts keep a longer `decisionDays` retention (default 30). Nothing is lost: aged-out entries are archived under archive/ (rooms/<chan>.jsonl, status.jsonl, inbox/<agent>.jsonl) — only receipts are truly deleted. `room` and `targets` compose: `room` scopes every sweep to that channel (and, alone, defaults the sweep to `rooms` only), while `targets` (rooms|status|inbox|receipts|members) selects which sweeps run and always wins over that default — so `{room, targets:['members']}` sweeps membership in that one channel. Sweeps room members that are unregistered or haven't heartbeated since the cutoff, and archives+removes non-default rooms left empty and inactive (disable via archiveEmptyRooms=false). Removes inbox files for agents no longer in the registry unless removeOrphanInboxes=false. Pass dryRun=true to preview.",
363
375
  pruneSchema,
364
376
  gate(null, pruneTool as (a: Record<string, unknown>) => Promise<unknown>),
365
377
  );
366
378
 
367
- server.tool(
379
+ addTool(
368
380
  "wait_for_message",
369
381
  "Block (max 60s) until a new message appears on the given source, then return it. For source='room', pass 'room' to wait on a specific channel (default 'general').",
370
382
  waitForMessageSchema,
371
383
  gate("agentId", waitForMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
372
384
  );
373
385
 
374
- server.tool(
386
+ addTool(
375
387
  "list_rooms",
376
388
  "List all channels with their topic, MOTD (room rules), members, message count, and last activity.",
377
389
  listRoomsSchema,
378
390
  gate(null, listRoomsTool as () => Promise<unknown>),
379
391
  );
380
392
 
381
- server.tool(
393
+ addTool(
382
394
  "join_room",
383
395
  "Join a channel (creating it if new). Adds this agent to the channel's membership so the notification hooks push its messages. Posts a system join notice to the channel. Returns the channel's topic, MOTD, member list, and unread message count — but not the messages themselves. Call read_messages to fetch history if needed.",
384
396
  joinRoomSchema,
385
397
  gate("agentId", joinRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
386
398
  );
387
399
 
388
- server.tool(
400
+ addTool(
389
401
  "leave_room",
390
402
  "Leave a channel — removes this agent from its membership. Cannot leave the default 'general' channel.",
391
403
  leaveRoomSchema,
392
404
  gate("agentId", leaveRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
393
405
  );
394
406
 
395
- server.tool(
407
+ addTool(
396
408
  "set_room_topic",
397
409
  "Set a channel's topic (a short one-line description). Posts a system notice to the channel.",
398
410
  setRoomTopicSchema,
399
411
  gate("agentId", setRoomTopicTool as (a: Record<string, unknown>) => Promise<unknown>),
400
412
  );
401
413
 
402
- server.tool(
414
+ addTool(
403
415
  "set_room_motd",
404
416
  "Set a channel's MOTD / room rules (shown to agents on join). Posts a system notice to the channel.",
405
417
  setRoomMotdSchema,
406
418
  gate("agentId", setRoomMotdTool as (a: Record<string, unknown>) => Promise<unknown>),
407
419
  );
408
420
 
409
- server.tool(
421
+ addTool(
410
422
  "rename_agent",
411
423
  "Rename an agent (NICK): migrates its registry entry, inbox, cursor, and channel memberships to the new id, then broadcasts a rename notice to its channels. When tokens.json identity binding is on, the caller's bearer token is atomically rotated to the new id so the same session keeps authenticating after rename. If a live tmux-push transport is attached it is detached first (the pusher is bound to the old id) — re-attach as the new id (join/attach_agent) to restore real-time delivery; the response sets detachedTransport + a warning when this happens.",
412
424
  renameAgentSchema,
@@ -438,84 +450,84 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
438
450
  },
439
451
  );
440
452
 
441
- server.tool(
453
+ addTool(
442
454
  "attach_agent",
443
455
  "Start the tmux-push transport for an agent: spawns hooks/tmux-pusher.mjs as a background process so peer DMs (and optionally room messages) get typed into the agent's tmux pane in real time. tmuxTarget defaults to the MCP server's own $TMUX_PANE if this server is running inside tmux. allowlist restricts which peer agentIds can push. Updates list_agents to show transport=tmux-push.",
444
456
  attachAgentSchema,
445
457
  gate("agentId", attachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
446
458
  );
447
459
 
448
- server.tool(
460
+ addTool(
449
461
  "detach_agent",
450
462
  "Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.",
451
463
  detachAgentSchema,
452
464
  gate("agentId", detachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
453
465
  );
454
466
 
455
- server.tool(
467
+ addTool(
456
468
  "report_transport",
457
469
  "Publish a transport marker for an agent (used by the remote tmux pusher, scripts/coord-pusher.mjs, to surface itself in list_agents). Set transport='tmux-push-remote' and optionally host/tmuxTarget. Liveness for remote markers is heartbeat-based — keep calling heartbeat or this marker gets GC'd after staleness.",
458
470
  reportTransportSchema,
459
471
  gate("agentId", reportTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
460
472
  );
461
473
 
462
- server.tool(
474
+ addTool(
463
475
  "clear_transport",
464
476
  "Idempotent delete of an agent's transport marker. The wire-callable counterpart to detach_agent for remote pushers: it only removes the marker — there's no local process to kill.",
465
477
  clearTransportSchema,
466
478
  gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
467
479
  );
468
480
 
469
- server.tool(
481
+ addTool(
470
482
  "report_receipt",
471
483
  "Append a delivery receipt for a message this agent's pusher just typed into its pane — the wire-callable counterpart to the local pusher's receipts/<id>.jsonl stamp, for remote pushers (scripts/coord-pusher.mjs) that cannot write this host's filesystem. This is what lets send_command to a tmux-push-remote agent return delivery:'confirmed'. For control commands pass exactly what submit verification observed (submitted/verified/reason); omitting 'submitted' means 'typed but unverified' and is reported as delivery:'pending', never 'confirmed'. 'agentId' (the receiving agent) is enforced against the session's bound identity, so a pusher can only stamp its own agent's receipt file.",
472
484
  reportReceiptSchema,
473
485
  gate("agentId", reportReceiptTool as (a: Record<string, unknown>) => Promise<unknown>),
474
486
  );
475
487
 
476
- server.tool(
488
+ addTool(
477
489
  "doctor",
478
490
  "Bus-wide health check: inspects the whole state dir and reports drift, leaks, and corruption (orphan transport markers / memberships / inboxes, cursor offsets past EOF, malformed JSONL, stale agents, oversized files, stale locks, channel/registry mismatches, environment). Read-only by default; pass fix=true to apply the safe, reversible repairs (malformed-line rewrites are backed up to .bak first). A clean report (healthy=true) means the bus is internally consistent.",
479
491
  doctorSchema,
480
492
  gate(null, doctorTool as (a: Record<string, unknown>) => Promise<unknown>),
481
493
  );
482
494
 
483
- server.tool(
495
+ addTool(
484
496
  "list_scopes",
485
497
  "Read the declared write scopes for managed documents (~/agent-coord/scopes.json). Call it with 'path' (and your 'agentId') to ask \"may I write this?\" BEFORE editing a shared doc like docs/QUEUE.md; call it bare to list every declared document and its owning role. ADVISORY ONLY: the bus does not mediate file writes, so this answers who owns a document, it does not stop anyone — enforcement arrives when work state moves into the store. Absent scopes.json means nothing is owned and nothing warns (opt-in).",
486
498
  listScopesSchema,
487
499
  gate(null, listScopesTool as (a: Record<string, unknown>) => Promise<unknown>),
488
500
  );
489
501
 
490
- server.tool(
502
+ addTool(
491
503
  "import_work",
492
- "Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date} and board rows. The markdown stays authoritative — this store is a derived index, and export_work renders it back byte-identically.",
504
+ "Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md and optional docs/FACTS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date}, board rows, and facts {id,claim,verified,by,method}. The markdown stays authoritative — this store is a derived index, and export_work renders queue/done/board back byte-identically (FACTS is not an export write target).",
493
505
  importWorkSchema,
494
506
  gate(null, importWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
495
507
  );
496
508
 
497
- server.tool(
509
+ addTool(
498
510
  "list_work",
499
- "Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields, and the board's lane rows. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
511
+ "Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields, the board's lane rows, and facts. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
500
512
  listWorkSchema,
501
513
  gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
502
514
  );
503
515
 
504
- server.tool(
516
+ addTool(
505
517
  "export_work",
506
- "Render a project's work documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last ' \u2014 ', date after a trailing ' \u00b7 '). Reports by default; pass write:true to rewrite the files. Refuses to export from an empty store rather than blanking a document. Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
518
+ "Render a project's work documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last ' \u2014 ', date after a trailing ' \u00b7 '). Reports by default; pass write:true to rewrite the files. Refuses to export from an empty store rather than blanking a document. Refuses write:true when that write would emit a new 5-col lanes-v0 table (parse-only; write grammar is workstreams.v1). Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
507
519
  exportWorkSchema,
508
520
  gate(null, exportWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
509
521
  );
510
522
 
511
- server.tool(
523
+ addTool(
512
524
  "delete_room",
513
525
  "Permanently delete a channel: removes it from the registry, deletes its JSONL file, and clears all agent cursor offsets for that channel. Refuses if agents are still joined unless force=true. Cannot delete the default 'general' channel. Posts a system notice to #general on success.",
514
526
  deleteRoomSchema,
515
527
  gate("agentId", deleteRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
516
528
  );
517
529
 
518
- server.tool(
530
+ addTool(
519
531
  "force_unregister",
520
532
  "Admin eviction: unregisters any agent by targetAgentId regardless of the caller's identity. Detaches the agent's transport, removes it from all channel memberships, and drops its registry entry. Use after a reboot to clean up stale agents that can no longer unregister themselves.",
521
533
  forceUnregisterSchema,