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.
- package/README.md +1 -1
- package/dist/build.js.map +1 -1
- package/dist/roles.js +10 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +39 -35
- package/dist/server.js.map +1 -1
- package/dist/tools/messaging.js +66 -5
- package/dist/tools/messaging.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 +78 -5
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +88 -34
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +1 -291
- 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 +10 -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-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 +51 -39
- package/src/tools/messaging.ts +62 -3
- package/src/tools/render.ts +1 -80
- package/src/tools/shared.ts +22 -77
- package/src/tools/transport.ts +78 -5
- package/src/tools/work.ts +107 -40
- package/src/work.ts +31 -386
- 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();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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": "^
|
|
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 = 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
|
-
//
|
|
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-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`
|
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.
|
|
244
|
+
version: "0.24.0",
|
|
244
245
|
});
|
|
245
246
|
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|