agent-coord-mcp 0.26.18 → 0.26.19
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/dist/build.js +113 -0
- package/dist/build.js.map +1 -0
- package/dist/capabilities.js +158 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/prefix.js +64 -0
- package/dist/prefix.js.map +1 -0
- package/dist/roles.js +132 -0
- package/dist/roles.js.map +1 -0
- package/dist/server-identity.js +82 -0
- package/dist/server-identity.js.map +1 -0
- package/dist/server.js +647 -0
- package/dist/server.js.map +1 -0
- package/dist/store.js +553 -0
- package/dist/store.js.map +1 -0
- package/dist/tools/admin.js +323 -0
- package/dist/tools/admin.js.map +1 -0
- package/dist/tools/attention.js +73 -0
- package/dist/tools/attention.js.map +1 -0
- package/dist/tools/away.js +285 -0
- package/dist/tools/away.js.map +1 -0
- package/dist/tools/board-ref.js +208 -0
- package/dist/tools/board-ref.js.map +1 -0
- package/dist/tools/event-kinds.js +39 -0
- package/dist/tools/event-kinds.js.map +1 -0
- package/dist/tools/events.js +234 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/index.js +14 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/logwatch.js +85 -0
- package/dist/tools/logwatch.js.map +1 -0
- package/dist/tools/messaging.js +706 -0
- package/dist/tools/messaging.js.map +1 -0
- package/dist/tools/record-events.js +380 -0
- package/dist/tools/record-events.js.map +1 -0
- package/dist/tools/records.js +1027 -0
- package/dist/tools/records.js.map +1 -0
- package/dist/tools/registry.js +500 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/render.js +2 -0
- package/dist/tools/render.js.map +1 -0
- package/dist/tools/rooms.js +210 -0
- package/dist/tools/rooms.js.map +1 -0
- package/dist/tools/rotate.js +143 -0
- package/dist/tools/rotate.js.map +1 -0
- package/dist/tools/scopes.js +126 -0
- package/dist/tools/scopes.js.map +1 -0
- package/dist/tools/shared.js +104 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/tools/stall.js +414 -0
- package/dist/tools/stall.js.map +1 -0
- package/dist/tools/transport.js +1878 -0
- package/dist/tools/transport.js.map +1 -0
- package/dist/tools/work.js +373 -0
- package/dist/tools/work.js.map +1 -0
- package/dist/tools/worktrees.js +581 -0
- package/dist/tools/worktrees.js.map +1 -0
- package/dist/typed-records.js +174 -0
- package/dist/typed-records.js.map +1 -0
- package/dist/work.js +2 -0
- package/dist/work.js.map +1 -0
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/tmux-pusher.mjs +0 -0
- package/package.json +11 -14
- package/scripts/coord-attention-clock.mjs +0 -0
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-stall-clock.mjs +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/probe-tmux-liveness.sh +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/scripts/typed-record-stats.mjs +0 -0
- package/src/server.ts +9 -1
- package/src/tools/worktrees.ts +234 -32
package/dist/server.js
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { coordAwaySchema, coordAwayTool, readAway, awayRefusal } from "./tools/away.js";
|
|
10
|
+
import { rotateSchema, rotateTool, rotateReconcileSchema, rotateReconcileTool } from "./tools/rotate.js";
|
|
11
|
+
import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
|
|
12
|
+
import { scanRecordEventsSchema, scanRecordEventsTool } from "./tools/record-events.js";
|
|
13
|
+
import { capabilitiesSchema, capabilitiesTool } from "./capabilities.js";
|
|
14
|
+
import { ensureDirs, getTokenMap, reloadTokenMapSync, sessionFile, listSessionFiles, readJson, } from "./store.js";
|
|
15
|
+
import { liveClaimEvidence, attachAgentSchema, attachAgentTool, clearTransportSchema, clearTransportTool, deleteRoomSchema, deleteRoomTool, detachAgentSchema, detachAgentTool, doctorSchema, doctorTool, forceUnregisterSchema, forceUnregisterTool, heartbeatSchema, heartbeatTool, joinRoomSchema, joinRoomTool, joinSchema, joinTool, leaveRoomSchema, leaveRoomTool, listAgentsSchema, listAgentsTool, pingSchema, pingTool, listRoomsSchema, listRoomsTool, postStatusSchema, postStatusTool, pruneSchema, pruneTool, readMessagesSchema, readMessagesTool, retrieveMessageSchema, retrieveMessageTool, retrieveRoomHistorySchema, retrieveRoomHistoryTool, registerSchema, registerTool, renameAgentSchema, renameAgentTool, reportReceiptSchema, reportReceiptTool, reportTransportSchema, reportTransportTool, sendCommandSchema, sendCommandTool, sendMessageSchema, sendMessageTool, setRoomMotdSchema, setRoomMotdTool, setRoomTopicSchema, setRoomTopicTool, statusSchema, statusTool, unregisterSchema, unregisterTool, quitSchema, quitTool, waitForMessageSchema, waitForMessageTool, listScopesSchema, listScopesTool, importWorkSchema, importWorkTool, stallCheckSchema, stallCheckTool, setHaltSchema, setHaltTool, lastRanSchema, stallClockStatusTool, ensureWorktreeSchema, ensureWorktreeTool, refreshWorktreesSchema, refreshWorktreesTool, releaseWorktreeSchema, releaseWorktreeTool, claimSchema, claimTool, landSchema, landTool, mergeSchema, mergeTool, nextUnblockedSchema, nextUnblockedTool, listWorkSchema, listWorkTool, exportWorkSchema, exportWorkTool, isPidAlive, } from "./tools/index.js";
|
|
16
|
+
function jsonResult(data) {
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// Build a fully-configured McpServer with every tool registered. Returns a
|
|
22
|
+
// fresh instance each call — in HTTP mode we need one server per session so
|
|
23
|
+
// transports don't share Protocol state.
|
|
24
|
+
//
|
|
25
|
+
// Identity binding (v0.7.0 + TOFU in v0.7.1):
|
|
26
|
+
// - `initialBound` (when set) pre-binds the session — from a bearer token
|
|
27
|
+
// (HTTP/tokens.json) or AGENT_COORD_BOUND_AGENT env (stdio).
|
|
28
|
+
// - Otherwise the session starts unbound. The first tool call that carries
|
|
29
|
+
// an agentId/from field captures that value as the session's binding —
|
|
30
|
+
// trust-on-first-use. Subsequent calls must match; mid-session identity
|
|
31
|
+
// switching (the PR #45 spoof shape) is rejected.
|
|
32
|
+
// - rename_agent updates the binding to the new id on success so the
|
|
33
|
+
// renamed session keeps working.
|
|
34
|
+
// - First-claim guard (v0.20.0): TOFU no longer lets a fresh session claim
|
|
35
|
+
// an id that is currently LIVE on the bus (fresh heartbeat, live
|
|
36
|
+
// transport, or another live bound session) — that silently created a
|
|
37
|
+
// second session acting as an already-running agent (hit live 2026-07-06:
|
|
38
|
+
// a dev session bound itself to `<project>-liaison`). A live-id claim needs
|
|
39
|
+
// the agent's TOKEN — `force` alone is refused against a provably live
|
|
40
|
+
// incumbent (q-314e0187, v0.26.0: `force` used to bypass this evidence
|
|
41
|
+
// check entirely, which is what let two live sessions hold
|
|
42
|
+
// `groundwork-kit-worker-3` at once on 2026-08-31). `force` still works
|
|
43
|
+
// for evidence the guard cannot verify at all, or an id verified absent.
|
|
44
|
+
// See guardFirstClaim for how absent vs unreadable vs live-but-unproven
|
|
45
|
+
// evidence is decided.
|
|
46
|
+
// - `trackSession` (stdio only): each successful bind writes a
|
|
47
|
+
// sessions/<id>.<pid>.<nonce>.json marker so doctor can SEE two live
|
|
48
|
+
// sessions bound to one id — closure state alone cannot be inspected
|
|
49
|
+
// from outside the process. Not tracked for HTTP sessions: tokens.json
|
|
50
|
+
// already enforces their identity and many share one pid, which would
|
|
51
|
+
// make pid-liveness meaningless.
|
|
52
|
+
function buildServer(initialBound, opts = {}) {
|
|
53
|
+
let bound = initialBound;
|
|
54
|
+
const trackSession = opts.trackSession ?? false;
|
|
55
|
+
let sessionMarker;
|
|
56
|
+
let exitHooksInstalled = false;
|
|
57
|
+
// Task 16.1: the stdio half of the leak `#174` closed for HTTP. `#169`
|
|
58
|
+
// shipped an idle-timeout version of this and was HELD after a live test
|
|
59
|
+
// proved the failure mode it risked: killing a session's process out from
|
|
60
|
+
// under a still-attached client is SILENT, NON-RECOVERABLE loss, and idle
|
|
61
|
+
// is not abandoned. This reaps on the same signal `#174` uses instead — a
|
|
62
|
+
// NEW bind for the SAME agent id — which needs no timer and no guess.
|
|
63
|
+
//
|
|
64
|
+
// Why a new bind is PROOF rather than a guess, mirroring `#174`'s
|
|
65
|
+
// `agentSessions` reasoning one process boundary over: `recordSessionBinding`
|
|
66
|
+
// only runs after `guardFirstClaim` has already authorized this claim —
|
|
67
|
+
// tofu (verified the id was NOT live), token (the holder's own credential),
|
|
68
|
+
// force (an explicit override) or same-pane (the same physical seat
|
|
69
|
+
// restarting). In every one of those four paths, whoever controls this
|
|
70
|
+
// agent id has already moved to THIS session, so any other live marker for
|
|
71
|
+
// the same id left over is provably superseded, not merely idle — the
|
|
72
|
+
// exact distinction `#169` could not make with a timer.
|
|
73
|
+
async function reapSupersededStdioSessions(agentId) {
|
|
74
|
+
for (const file of await listSessionFiles()) {
|
|
75
|
+
const s = await readJson(file, null);
|
|
76
|
+
if (!s || s.agentId !== agentId || s.pid === process.pid)
|
|
77
|
+
continue;
|
|
78
|
+
if (!isPidAlive(s.pid))
|
|
79
|
+
continue; // already gone; nothing to signal
|
|
80
|
+
console.error(`[agent-coord-mcp] reaping stdio session pid ${s.pid} for agent '${agentId}' — superseded by this session (pid ${process.pid})`);
|
|
81
|
+
try {
|
|
82
|
+
// SIGTERM, not a kill(9): the superseded process's OWN exit hook
|
|
83
|
+
// (below) unlinks its marker and exits 0 — the same graceful path
|
|
84
|
+
// an operator's `quit` or Ctrl-C would take, not a hard kill.
|
|
85
|
+
process.kill(s.pid, "SIGTERM");
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error(`[agent-coord-mcp] reap FAILED for pid ${s.pid}:`, err);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Best-effort: a marker left behind by SIGKILL has a dead pid, which both
|
|
93
|
+
// the guard's evidence read and doctor's duplicate-session-binding check
|
|
94
|
+
// treat as garbage (doctor fix deletes it).
|
|
95
|
+
function recordSessionBinding(agentId, via) {
|
|
96
|
+
if (!trackSession)
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
const file = sessionFile(agentId, process.pid, randomUUID().slice(0, 8));
|
|
100
|
+
const marker = {
|
|
101
|
+
agentId,
|
|
102
|
+
pid: process.pid,
|
|
103
|
+
boundAt: Date.now(),
|
|
104
|
+
via,
|
|
105
|
+
...(process.env.TMUX_PANE ? { tmuxPane: process.env.TMUX_PANE } : {}),
|
|
106
|
+
};
|
|
107
|
+
writeFileSync(file, JSON.stringify(marker, null, 2) + "\n");
|
|
108
|
+
if (sessionMarker) {
|
|
109
|
+
try {
|
|
110
|
+
unlinkSync(sessionMarker);
|
|
111
|
+
}
|
|
112
|
+
catch { /* already gone */ }
|
|
113
|
+
}
|
|
114
|
+
sessionMarker = file;
|
|
115
|
+
if (!exitHooksInstalled) {
|
|
116
|
+
exitHooksInstalled = true;
|
|
117
|
+
const cleanup = () => {
|
|
118
|
+
try {
|
|
119
|
+
if (sessionMarker)
|
|
120
|
+
unlinkSync(sessionMarker);
|
|
121
|
+
}
|
|
122
|
+
catch { /* already gone */ }
|
|
123
|
+
};
|
|
124
|
+
process.on("exit", cleanup);
|
|
125
|
+
// Default signal death skips 'exit' handlers; SIGHUP stays reserved
|
|
126
|
+
// for the token-map reload in loadTokenMap.
|
|
127
|
+
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
128
|
+
process.on(sig, () => { cleanup(); process.exit(0); });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
reapSupersededStdioSessions(agentId).catch((err) => console.error("[agent-coord-mcp] stdio supersession reap threw:", err));
|
|
132
|
+
}
|
|
133
|
+
catch { /* marker is observability, never worth failing the bind */ }
|
|
134
|
+
}
|
|
135
|
+
// Decide whether a fresh session may claim `claimed` as its identity, and
|
|
136
|
+
// how. Returns the bind provenance ("tofu" | "token" | "force" |
|
|
137
|
+
// "same-pane") or throws. Ordering is deliberate:
|
|
138
|
+
// - a presented token must MATCH or the claim fails loudly, even when the
|
|
139
|
+
// id is not live — a wrong credential silently succeeding via the
|
|
140
|
+
// not-live path would teach callers that garbage tokens work;
|
|
141
|
+
// - evidence is gathered BEFORE `force` is consulted (q-314e0187): a live
|
|
142
|
+
// PROVABLE incumbent can no longer be superseded by `force` alone — see
|
|
143
|
+
// below. `force` is still honored, but only where it was never the
|
|
144
|
+
// defect: unverifiable evidence, or a verified-absent id;
|
|
145
|
+
// - evidence that exists but cannot be read REFUSES unless `force` is
|
|
146
|
+
// passed (cannot-verify ≠ verified-absent; unreadable state must not
|
|
147
|
+
// silently disable the guard, but an explicit override still works —
|
|
148
|
+
// there is no incumbent to protect from being duplicated here);
|
|
149
|
+
// - a live id refuses, except when its live pusher types into THIS
|
|
150
|
+
// process's own tmux pane — two sessions cannot share a pane, so that
|
|
151
|
+
// is the same seat restarting (the routine fleet-restart case), not a
|
|
152
|
+
// second session. The exception never applies when another live
|
|
153
|
+
// session is already bound to the id.
|
|
154
|
+
// - verified-not-live binds freely: refusing absent evidence would break
|
|
155
|
+
// every first onboarding, and the guard exists to protect LIVE ids.
|
|
156
|
+
async function guardFirstClaim(claimed, args) {
|
|
157
|
+
const token = typeof args["token"] === "string" ? args["token"] : undefined;
|
|
158
|
+
if (token !== undefined) {
|
|
159
|
+
if (getTokenMap()?.get(token) === claimed)
|
|
160
|
+
return "token";
|
|
161
|
+
throw new Error(`token presented for '${claimed}' does not match tokens.json (or no token map is loaded). ` +
|
|
162
|
+
`Mint one with scripts/coord-token.mjs add ${claimed} (then SIGHUP the bus), or pass force:true if you are certain.`);
|
|
163
|
+
}
|
|
164
|
+
const ev = await liveClaimEvidence(claimed, Date.now());
|
|
165
|
+
if (!ev.verifiable) {
|
|
166
|
+
if (args["force"] === true)
|
|
167
|
+
return "force";
|
|
168
|
+
throw new Error(`cannot verify whether '${claimed}' is live: ${ev.reasons.join("; ")}. ` +
|
|
169
|
+
`Refusing to bind rather than treating unreadable evidence as absence. ` +
|
|
170
|
+
`Repair the state (doctor), or pass the agent's token or force:true (join/register).`);
|
|
171
|
+
}
|
|
172
|
+
if (ev.live) {
|
|
173
|
+
if (ev.samePane && ev.boundElsewhere === 0)
|
|
174
|
+
return "same-pane";
|
|
175
|
+
// A LEFTOVER SESSION IS NAMED AS ONE, AND force NO LONGER REMOVES IT
|
|
176
|
+
// EITHER — q-314e0187: `force:true` used to be checked BEFORE this
|
|
177
|
+
// evidence was even gathered (line order, not this message), so a
|
|
178
|
+
// provably-live incumbent was never actually protected — it just
|
|
179
|
+
// looked protected to anyone who did not pass `force`. Measured
|
|
180
|
+
// 2026-08-31: `doctor` found pid 7129 (via FORCE) and pid 9760 (via
|
|
181
|
+
// same-pane) both live and bound to `groundwork-kit-worker-3`
|
|
182
|
+
// simultaneously, and the bus log could not tell which one wrote a
|
|
183
|
+
// disputed message. `force` binding past a live session does not stop
|
|
184
|
+
// that process — it adds a second claimant to one identity, which is
|
|
185
|
+
// the condition that produced exactly that incident. So a provably
|
|
186
|
+
// live incumbent can now be superseded ONLY by its own token; `force`
|
|
187
|
+
// is refused here regardless of its value.
|
|
188
|
+
//
|
|
189
|
+
// A live TRANSPORT marker is a different fact and is not called a leak:
|
|
190
|
+
// that pid is the pusher daemon, doing its job. Only a live SESSION
|
|
191
|
+
// binding that is not this process is a leftover.
|
|
192
|
+
const leaks = ev.leakedSessions ?? [];
|
|
193
|
+
const leakLine = leaks.length
|
|
194
|
+
? ` LEFTOVER PROCESS${leaks.length > 1 ? "ES" : ""} HOLDING THIS IDENTITY: ` +
|
|
195
|
+
leaks.map((l) => `pid ${l.pid} (via ${l.via})`).join(", ") +
|
|
196
|
+
`. Look at ${leaks.length > 1 ? "them" : "it"} before deciding — \`ps -p ${leaks.map((l) => l.pid).join(",")}\`.`
|
|
197
|
+
: "";
|
|
198
|
+
throw new Error(`agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it` +
|
|
199
|
+
(args["force"] === true ? ", even with force:true" : "") +
|
|
200
|
+
`.` +
|
|
201
|
+
leakLine +
|
|
202
|
+
` A live incumbent can only be superseded with ITS OWN TOKEN now (mint one: scripts/coord-token.mjs add ${claimed}, then SIGHUP the bus) — ` +
|
|
203
|
+
`\`force:true\` binds a SECOND session to one identity rather than stopping the first, which is the defect this refusal exists to prevent, not a step past it. ` +
|
|
204
|
+
`If you ARE '${claimed}' restarting, re-join from its own tmux pane${leaks.length ? " once nothing else holds it" : ""}. ` +
|
|
205
|
+
`If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`);
|
|
206
|
+
}
|
|
207
|
+
if (args["force"] === true)
|
|
208
|
+
return "force";
|
|
209
|
+
return "tofu";
|
|
210
|
+
}
|
|
211
|
+
// Gate every tool that takes a caller identity. `field: null` (list_agents,
|
|
212
|
+
// list_rooms, prune) bypasses the check entirely.
|
|
213
|
+
//
|
|
214
|
+
// `bindOnClaim: false` (status, ping) means the tool still enforces a
|
|
215
|
+
// mismatch against an *existing* binding, but a fresh (unbound) session
|
|
216
|
+
// never claims one just by naming an agentId — a diagnostic status/ping
|
|
217
|
+
// call must not be able to silently TOFU-bind a session to some other,
|
|
218
|
+
// already-live agent's identity.
|
|
219
|
+
function gate(field, handler, { bindOnClaim = true } = {}) {
|
|
220
|
+
return async (args) => {
|
|
221
|
+
if (field) {
|
|
222
|
+
const claimed = args[field];
|
|
223
|
+
if (typeof claimed === "string") {
|
|
224
|
+
if (bound === undefined) {
|
|
225
|
+
if (bindOnClaim) {
|
|
226
|
+
// TOFU: first claim wins, then sticky — but only after the
|
|
227
|
+
// first-claim guard agrees the id isn't someone else's live
|
|
228
|
+
// session (see guardFirstClaim).
|
|
229
|
+
const via = await guardFirstClaim(claimed, args);
|
|
230
|
+
bound = claimed;
|
|
231
|
+
recordSessionBinding(claimed, via);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (bound !== claimed) {
|
|
235
|
+
throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return jsonResult(await handler(args));
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const server = new McpServer({
|
|
243
|
+
name: "agent-coord",
|
|
244
|
+
version: "0.25.5",
|
|
245
|
+
});
|
|
246
|
+
const addTool = (name, description, inputSchema, cb) => {
|
|
247
|
+
server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) => {
|
|
248
|
+
const a = (args ?? {});
|
|
249
|
+
// DUTY-OFFICER ALLOWLIST, ENFORCED IN THE ONE PATH EVERY TOOL IS
|
|
250
|
+
// REGISTERED THROUGH. Placed here rather than in `gate` because `gate`
|
|
251
|
+
// does not know the tool's NAME, and because a guard applied per call
|
|
252
|
+
// site is a guard someone forgets at one call site — where the omission
|
|
253
|
+
// looks identical to the guarded ones from outside (kit#125).
|
|
254
|
+
const caller = bound ?? (typeof a["agentId"] === "string" ? a["agentId"] : typeof a["from"] === "string" ? a["from"] : undefined);
|
|
255
|
+
const refusal = awayRefusal(readAway(), caller, name);
|
|
256
|
+
if (refusal)
|
|
257
|
+
throw new Error(refusal);
|
|
258
|
+
return cb(a);
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
addTool("join", "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 — `force:true` no longer overrides a provably live incumbent (only the token does); it still works when liveness cannot be verified at all or the id is verified absent. Diagnosing someone else's agent is what status/ping are for. `proseOnly:true` claims the per-agent exemption from the typed-record rule — see `register`.", joinSchema,
|
|
262
|
+
// join explicitly sets the session binding when unset, so each agent can
|
|
263
|
+
// declare its identity via join rather than relying on env vars.
|
|
264
|
+
async (args) => {
|
|
265
|
+
const claimed = args["agentId"];
|
|
266
|
+
if (typeof claimed === "string") {
|
|
267
|
+
if (bound === undefined) {
|
|
268
|
+
const via = await guardFirstClaim(claimed, args);
|
|
269
|
+
bound = claimed;
|
|
270
|
+
recordSessionBinding(claimed, via);
|
|
271
|
+
}
|
|
272
|
+
else if (bound !== claimed) {
|
|
273
|
+
throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return jsonResult(await joinTool(args));
|
|
277
|
+
});
|
|
278
|
+
addTool("register", "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. `proseOnly:true` claims the per-agent exemption from the typed-record rule, for a model that cannot reliably pick a record.type; it is visible and counted in list_agents, and it is granted to the SENDER but paid by every READER (an untyped message cannot be slimmed). Omit it to leave any existing exemption untouched; pass false to revoke.", registerSchema, gate("agentId", registerTool));
|
|
279
|
+
addTool("unregister", "Tear down this agent: detach any attached transport (kills the pusher) and remove the registry entry. Clean shutdown counterpart to `join`.", unregisterSchema, gate("agentId", unregisterTool));
|
|
280
|
+
addTool("quit", "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.", quitSchema, gate("agentId", quitTool));
|
|
281
|
+
addTool("status", "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.", statusSchema, gate("agentId", statusTool, { bindOnClaim: false }));
|
|
282
|
+
addTool("heartbeat", "Refresh this agent's lastHeartbeat timestamp.", heartbeatSchema, gate("agentId", heartbeatTool));
|
|
283
|
+
addTool("ping", "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 (live transport, or a fresh heartbeat ONLY where something writes one — never a local tmux-push transport's, whose heartbeat measures time since it joined rather than activity), reachable (a DM pushed now would land), granular checks including heartbeatValid (whether heartbeatFresh next to it is real evidence for this transport), 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.", pingSchema, gate("from", pingTool, { bindOnClaim: false }));
|
|
284
|
+
addTool("list_agents", "List all known agents and whether they appear online (heartbeat <5min). Also reports the prose-only exemptions from the typed-record rule — who holds one, since when, and the count over the total, so a rising exempt share is visible rather than inferred.", listAgentsSchema, gate(null, listAgentsTool));
|
|
285
|
+
addTool("send_message", "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. EVERY AGENT→AGENT MESSAGE MUST CARRY 'record' with a typed 'type' (decision · verdict · done · blocker · risk · fyi · action · go · scope): a typed multi-line message is delivered as ONE attributed line plus a retrieve_message handle, while an untyped one arrives in full in every reader's context. 'fyi' is the honest catch-all — use it rather than forcing a false 'decision'/'risk'. Untyped sends WARN today and are REFUSED from 2026-09-15. Messages TO a human are exempt (David-facing traffic stays prose), as is a sender that declared proseOnly:true at join.", sendMessageSchema, gate("from", sendMessageTool));
|
|
286
|
+
addTool("send_command", "Inject a context-management slash command (/clear, /compact or /reload-skills) 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, /compact and /reload-skills; nothing else is accepted. 'from' is enforced against the session's bound identity.", sendCommandSchema, gate("from", sendCommandTool));
|
|
287
|
+
addTool("read_messages", "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.", readMessagesSchema, gate("agentId", readMessagesTool));
|
|
288
|
+
addTool("retrieve_room_history", "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.", retrieveRoomHistorySchema, gate("agentId", retrieveRoomHistoryTool));
|
|
289
|
+
addTool("retrieve_message", "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.", retrieveMessageSchema, gate("agentId", retrieveMessageTool));
|
|
290
|
+
addTool("post_status", "Append a status broadcast to the shared status stream.", postStatusSchema, gate("agentId", postStatusTool));
|
|
291
|
+
addTool("prune", "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.", pruneSchema, gate(null, pruneTool));
|
|
292
|
+
addTool("wait_for_message", "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').", waitForMessageSchema, gate("agentId", waitForMessageTool));
|
|
293
|
+
addTool("list_rooms", "List all channels with their topic, MOTD (room rules), members, message count, and last activity.", listRoomsSchema, gate(null, listRoomsTool));
|
|
294
|
+
addTool("join_room", "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.", joinRoomSchema, gate("agentId", joinRoomTool));
|
|
295
|
+
addTool("leave_room", "Leave a channel — removes this agent from its membership. Cannot leave the default 'general' channel.", leaveRoomSchema, gate("agentId", leaveRoomTool));
|
|
296
|
+
addTool("set_room_topic", "Set a channel's topic (a short one-line description). Posts a system notice to the channel.", setRoomTopicSchema, gate("agentId", setRoomTopicTool));
|
|
297
|
+
addTool("set_room_motd", "Set or EDIT a channel's MOTD / room rules (shown to agents on join). Pass exactly ONE of: `motd` (replace the whole thing), `motdAppend` (add a clause, newline-joined), or `motdPatch:{find,replace}` (replace one unique substring — refuses if `find` matches zero times or more than once, rather than silently no-op'ing or patching the wrong clause). Prefer append/patch: a whole-string rewrite of a large MOTD costs as much as the doctrine it corrects, which is how stale rules survive. The response does NOT echo the MOTD body back (read it with list_rooms) and reports `bytes`/`bytesBefore`, plus a warning past 4096 bytes suggesting the doctrine move to a repo file with a short pointer here. Posts a system notice to the channel.", setRoomMotdSchema, gate("agentId", setRoomMotdTool));
|
|
298
|
+
addTool("rename_agent", "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.", renameAgentSchema,
|
|
299
|
+
// Special: after a successful rename we update the session's bound id
|
|
300
|
+
// too, so the same session can keep operating under the new name without
|
|
301
|
+
// the next call being rejected as a binding mismatch.
|
|
302
|
+
async (args) => {
|
|
303
|
+
const claimed = args.agentId;
|
|
304
|
+
if (typeof claimed === "string") {
|
|
305
|
+
if (bound === undefined) {
|
|
306
|
+
// Renaming a live agent from a fresh session is still a first
|
|
307
|
+
// claim of that agent's identity — same guard as any other.
|
|
308
|
+
const via = await guardFirstClaim(claimed, args);
|
|
309
|
+
bound = claimed;
|
|
310
|
+
recordSessionBinding(claimed, via);
|
|
311
|
+
}
|
|
312
|
+
else if (bound !== claimed) {
|
|
313
|
+
throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const result = await renameAgentTool(args);
|
|
317
|
+
if (result && typeof result === "object" && result.ok === true) {
|
|
318
|
+
const to = result.to;
|
|
319
|
+
if (typeof to === "string") {
|
|
320
|
+
bound = to;
|
|
321
|
+
recordSessionBinding(to, "rename");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return jsonResult(result);
|
|
325
|
+
});
|
|
326
|
+
addTool("attach_agent", "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.", attachAgentSchema, gate("agentId", attachAgentTool));
|
|
327
|
+
addTool("detach_agent", "Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.", detachAgentSchema, gate("agentId", detachAgentTool));
|
|
328
|
+
addTool("report_transport", "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.", reportTransportSchema, gate("agentId", reportTransportTool));
|
|
329
|
+
addTool("clear_transport", "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.", clearTransportSchema, gate("agentId", clearTransportTool));
|
|
330
|
+
addTool("report_receipt", "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.", reportReceiptSchema, gate("agentId", reportReceiptTool));
|
|
331
|
+
addTool("doctor", "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.", doctorSchema, gate(null, doctorTool));
|
|
332
|
+
addTool("list_scopes", "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).", listScopesSchema, gate(null, listScopesTool));
|
|
333
|
+
addTool("import_work", "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).", importWorkSchema, gate(null, importWorkTool));
|
|
334
|
+
addTool("list_work", "A re-parsing VIEW over a project's work state, not an index: it re-reads the documents (or the last import_work snapshot, reported as source:\"store\" vs \"markdown\") rather than answering from a maintained store, so treat repeated calls as costing what a fresh parse costs. Queue items come back as IDENTITY ONLY — id, priority, a bounded headline (truncated:true when cut, never silently), blockedBy — never the full body; pass id back on a second call to fetch one item's or one DONE entry's record whole. Done entries, board lane rows, and facts are returned in full. Filter queue by priority; falls back to reading the documents directly when nothing has been imported, so it works with no store at all.", listWorkSchema, gate(null, listWorkTool));
|
|
335
|
+
addTool("stall_check", "The stall predicate over the board and the bus: a 🚧 In Progress or 🔍 In Review row (both are lanes somebody is working; a review that never gets re-gated is the stall shape of a QA-gated fleet) whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.", stallCheckSchema, gate(null, stallCheckTool));
|
|
336
|
+
addTool("stall_clock_status", "Is the stall clock alive? Reports when stall_check last ran, how many runs, and how many were misses — so 'no alerts' is distinguishable from 'nothing ran'. Never having run is an ERROR, not a quiet fleet.", lastRanSchema, gate(null, stallClockStatusTool));
|
|
337
|
+
addTool("set_halt", "Set or clear a NAMED halt. While set, `claim` and `next_unblocked` refuse. The reason is required because a halt blocks every lane in the fleet: it must name a board cutover, a cited BLOCKER: or a documented red pipeline — 'production feels down' is not a halt.", setHaltSchema, gate(null, setHaltTool));
|
|
338
|
+
addTool("ensure_worktree", "Create or reuse an isolated git worktree for an agent, cut from origin/<base> (never a local branch of the same name, which is whatever the last person left there). Refuses to hand back the PRIMARY checkout as a slice tree — that is the path everyone already has, so it is the one two agents end up editing at once. Idempotent: an existing tree is reported as found, and if it sits on a different branch that is said rather than re-pointed.", ensureWorktreeSchema, gate(null, ensureWorktreeTool));
|
|
339
|
+
addTool("refresh_worktrees", "Fast-forward IDLE worktrees onto origin/<base>. Never --force and never a mid-slice tree: dirty or holding commits the base does not is REFUSED per tree and named, because staleness is visible in a diff and a clobbered work-in-progress is not. Reports by default; pass apply:true to move them.", refreshWorktreesSchema, gate(null, refreshWorktreesTool));
|
|
340
|
+
addTool("release_worktree", "GIVE A WORKTREE BACK — the counterpart to ensure_worktree, which created trees while nothing released them, leaving `git worktree remove`/`prune` as a hand step in five role cards. Identify the tree by `agentId` or `path`. REFUSES the primary checkout (the path everyone already has); REFUSES a DIRTY tree with no override, because uncommitted work is what a diff cannot show and git cannot give back; and REFUSES a tree whose commits cannot be shown to have landed. Landedness is decided by PATCH ID, never ancestry — under squash merge a landed branch's shas never enter the base, so ancestry calls every finished tree mid-slice forever and would refuse exactly the trees that are safe to release. A patch-id negative is INCONCLUSIVE rather than a no, so it refuses and says so; `force:true` releases anyway and is recorded in the result. Removing and pruning are ONE act here, not two lines of prose the caller remembers. Reports by default; pass apply:true to remove.", releaseWorktreeSchema, gate(null, releaseWorktreeTool));
|
|
341
|
+
addTool("next_unblocked", "The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also skips — as its own axis, `awaitingDecision`, never silently — an item that DECLARES it awaits a named human (`**[AWAITS:<who>]**` leading the item line; never inferred from prose): dependency-free and unbuildable at once, so it is reported for the human and not routed to a lane. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.", nextUnblockedSchema, gate(null, nextUnblockedTool));
|
|
342
|
+
addTool("claim", "Bind a queue item to an agent, ENSURE THE AGENT HAS AN ISOLATED WORKTREE, and produce the \ud83d\udea7 board row. With no itemId it takes next_unblocked. `claim` CALLS `ensure_worktree` rather than telling you to — a worktree that cannot be ensured is a REFUSAL, not a warning, because binding an item to an agent with nowhere isolated to work is the shared-checkout failure this pair exists to prevent. A REUSED tree that is not at origin/<base> is also refused, naming how far behind it is and pointing at `refresh_worktrees`: `claim` means START SOMETHING NEW, and a tree left on last week's base produces confidently-wrong work with nothing about the result looking stale. A freshly created tree is cut from origin/<base> and needs no such check. Returns `worktreeEnsured` with the tree's path and branch, and writes the board cell as the REMOTE-TRACKING ref so the same row reads identically from every checkout.", claimSchema, gate(null, claimTool));
|
|
343
|
+
addTool("land", "Record a merged PR: refuses without a cited PR number, and refuses unless the PR is on the TARGET TIP (origin/<base>) rather than a merge base — an item merged after your branch was cut is missing from the base too, so the base cannot answer 'what does the thing I am merging into have that I do not?'. Closes the queue item STATUS ONLY (priority and body bytes unchanged), proposes the DONE entry, and reports by default: pass write:true to apply.", landSchema, gate(null, landTool));
|
|
344
|
+
addTool("merge", "Merge a PR ONLY as the consequence of its check verdict: reads the status rollup and merges in the same call, so there is no ordering in which the check runs and the merge ignores it. Refuses on any failing check, on any check not yet terminal, on a CONFLICTING base, and on ZERO checks \u2014 no checks is not passing checks, an empty rollup has zero failures and evidences nothing. Every return carries the POPULATION it judged. Reports by default; pass write:true to merge.", mergeSchema, gate(null, mergeTool));
|
|
345
|
+
addTool("coord_away", "Declare that DAVID IS AWAY and the aide LEADS, or RELEASE it. (Redefined 2026-08-30; the old duty-officer meaning is RETIRED, not deprecated \u2014 one label, one meaning.) The lead DECIDES planning, priority, queue curation, roadmap, canon and releases under standing authorisation; NEVER merges, gates, or takes a code lane (enforced: `merge` and `land` are refused with their reason); and PARKS licence, funding, public-vs-private, credentials, anything altering scope or authority, and destructive machine actions \u2014 those are not the aide's to decide and an absence is not a transfer of that authority. Requires a `leadId` (an unnamed lead reports the fleet as led while leaving it unled) and a `decisionLog` path, so David returns to a diff rather than a transcript. THE PRECONDITION IS MEASURED COVERAGE, NOT A CLOCK RUN: it reads `stall_check`'s own unmeasurable list \u2014 never `stall_clock_status`'s green, which cannot express its own blindness \u2014 and REFUSES when the clock would run and see nothing, unless `acknowledgeBlindFleet:true` records that the promise is being made anyway.", coordAwaySchema, gate("agentId", coordAwayTool));
|
|
346
|
+
addTool("rotate", "Build a handover-to-self packet from LIVE state (git + gh), never chat memory \u2014 memory is the one source that cannot be re-derived after the reset it is meant to survive. REFUSES while the tree is dirty: uncommitted work is the one thing a packet cannot carry, and after /clear nothing can discover it existed. Jobs are an allowlist (reseed-only | phase-boundary); 'archive-done' is deliberately absent. Reports by default; write:true persists the packet.", rotateSchema, gate("agentId", rotateTool));
|
|
347
|
+
addTool("rotate_reconcile", "FIRST ACT AFTER A RESEED. Re-checks every packet claim against live state and REFUSES to report ready on any divergence \u2014 a PR that merged during the reset is the dangerous direction, because the agent resumes a branch already in main and every next step is coherent and wrong. 'missionHint' is reported as UNVERIFIABLE rather than counted as reconciled.", rotateReconcileSchema, gate("agentId", rotateReconcileTool));
|
|
348
|
+
addTool("subscribe", "Register for a record event: `task` (a phase checkbox newly ticked), `phase` (the last open box in a phase ticked), `item` (a queue item closed), or `pr` (a PR recorded in DONE.md). Events are DERIVED from the record and refused if the ref is not in it, so the stream can never claim something the authoritative markdown does not \u2014 but the TRIGGER is the record's COMMITTED CHANGE (`scan_record_events`), not any one verb, so a hand-edited DONE.md fires exactly like `land` does. Every offered kind has an emitter: the wire enum is generated from the emitter registry, so an unsatisfiable subscription cannot be registered. Re-subscribing returns the existing registration rather than a duplicate.", subscribeSchema, gate("agentId", subscribeTool));
|
|
349
|
+
addTool("capabilities", "What THIS SERVER PROCESS can actually do, answered by CALLING each capability rather than by reading a version, an mtime, or a file. No timestamp on the box can answer a capability question: an mtime tracks writes (a reinstall of identical bytes moves it), `serverBuildMtime` is stamped at attach, and a version is a label someone typed — a published tarball has been observed carrying a change its version said it could not. Each probe reports the call it made and what came back, so an absent capability reads as an absence rather than an error. THE ANSWER IS ABOUT ONE PROCESS: a release is delivered only when every live agent's OWN server answers, which is the fifth state (merged \u00b7 published \u00b7 installed \u00b7 restarted \u00b7 observed). A server that has not restarted answers honestly about the old code it still runs.", capabilitiesSchema, gate(null, capabilitiesTool));
|
|
350
|
+
addTool("scan_record_events", "Turn a repo's COMMITTED record changes into events: new `docs/DONE.md` entries, queue items that left `docs/QUEUE.md`, and newly-ticked phase checkboxes, between the stored watermark and HEAD. The commit is the boundary \u2014 an uncommitted edit is not yet a record. A HAND-EDIT fires exactly like `land` does, which is the point: fleets merge with `gh` and edit DONE.md directly. Reports by default; `write:true` delivers and advances the watermark. Re-scanning a delivered range is safe (the idempotency key comes from the event, so it reports duplicate-suppressed), and a watermark that no longer resolves REFUSES rather than silently narrowing its window.", scanRecordEventsSchema, gate(null, scanRecordEventsTool));
|
|
351
|
+
addTool("unsubscribe", "Remove one of YOUR subscriptions. Refuses another agent's: silently dropping someone else's notification is how a miss is manufactured.", unsubscribeSchema, gate("agentId", unsubscribeTool));
|
|
352
|
+
addTool("list_subscriptions", "List subscriptions with their health. A subscription NEVER EVALUATED is an ERROR, not a quiet zero \u2014 'no events yet' and 'never ran' are the same output and only one is healthy. Carries its population, because 'no subscriptions' and 'none listed for you' are different claims.", listSubscriptionsSchema, gate(null, listSubscriptionsTool));
|
|
353
|
+
addTool("export_work", "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.", exportWorkSchema, gate(null, exportWorkTool));
|
|
354
|
+
addTool("delete_room", "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.", deleteRoomSchema, gate("agentId", deleteRoomTool));
|
|
355
|
+
addTool("force_unregister", "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.", forceUnregisterSchema, gate(null, forceUnregisterTool));
|
|
356
|
+
// An env pre-bound session is just as live as a TOFU-bound one — record it
|
|
357
|
+
// so doctor's duplicate check sees it too. (No-op unless trackSession.)
|
|
358
|
+
if (initialBound)
|
|
359
|
+
recordSessionBinding(initialBound, "env");
|
|
360
|
+
return server;
|
|
361
|
+
}
|
|
362
|
+
// Token map for HTTP identity binding, held in-process via store.ts's
|
|
363
|
+
// shared cache. Hot-reloaded on SIGHUP so operators can rotate / add agents
|
|
364
|
+
// without a server restart; also refreshed automatically by rename_agent
|
|
365
|
+
// (see rotateAgentToken) so a live rename doesn't need one.
|
|
366
|
+
function loadTokenMap(initial) {
|
|
367
|
+
try {
|
|
368
|
+
reloadTokenMapSync();
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
// On initial load a bad file is fatal — refuse to start in a known-bad
|
|
372
|
+
// auth state. On SIGHUP, log and keep the previous (valid) map.
|
|
373
|
+
if (initial) {
|
|
374
|
+
console.error(e.message);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
console.error(`[agent-coord-mcp] SIGHUP: ${e.message} (keeping previous map)`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!initial) {
|
|
381
|
+
console.error(`[agent-coord-mcp] SIGHUP: token map reloaded (${getTokenMap()?.size ?? 0} agents)`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async function main() {
|
|
385
|
+
ensureDirs();
|
|
386
|
+
loadTokenMap(true);
|
|
387
|
+
process.on("SIGHUP", () => loadTokenMap(false));
|
|
388
|
+
// Transport selector. AGENT_COORD_HTTP_PORT set → run as a long-lived HTTP
|
|
389
|
+
// daemon (Streamable HTTP transport + bearer-token auth). Otherwise the
|
|
390
|
+
// historical stdio behavior (per-client subprocess spawned by Claude Code).
|
|
391
|
+
const httpPort = process.env.AGENT_COORD_HTTP_PORT;
|
|
392
|
+
if (httpPort) {
|
|
393
|
+
await startHttp(parseInt(httpPort, 10));
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
const boundAgent = process.env.AGENT_COORD_BOUND_AGENT;
|
|
397
|
+
if (!boundAgent) {
|
|
398
|
+
console.error("[agent-coord-mcp] WARNING: AGENT_COORD_BOUND_AGENT is not set.\n" +
|
|
399
|
+
" The session identity will be locked to whatever agentId is used in the first\n" +
|
|
400
|
+
" tool call (TOFU). This cannot be changed mid-session.\n" +
|
|
401
|
+
" To fix: add AGENT_COORD_BOUND_AGENT=<your-agent-id> to the MCP server env\n" +
|
|
402
|
+
" in your Claude Code MCP config, then restart. Use the `quit` tool to cleanly\n" +
|
|
403
|
+
" unregister before restarting so the new name starts fresh.");
|
|
404
|
+
}
|
|
405
|
+
const server = buildServer(boundAgent, { trackSession: true });
|
|
406
|
+
const transport = new StdioServerTransport();
|
|
407
|
+
// StdioServerTransport only wires `data`/`error` on stdin — it never
|
|
408
|
+
// observes EOF. A client that ends the pipe (Claude Code's `/mcp`
|
|
409
|
+
// reconnect calls `stdin.end()` on the child) leaves this process with
|
|
410
|
+
// nothing listening for it, so it idles forever: measured as 25 live
|
|
411
|
+
// servers against 15 registered agents, one pane holding 3 generations.
|
|
412
|
+
// React to the session ending from EITHER direction — the read side
|
|
413
|
+
// closing (client done talking to us) or the write side breaking
|
|
414
|
+
// (client stopped listening, so a reply would EPIPE) — rather than
|
|
415
|
+
// waiting on the SDK client's own SIGTERM escalation to reach us.
|
|
416
|
+
const exitOnSessionEnd = (reason) => {
|
|
417
|
+
console.error(`[agent-coord-mcp] stdio session ended (${reason}) — exiting`);
|
|
418
|
+
process.exit(0);
|
|
419
|
+
};
|
|
420
|
+
process.stdin.on("end", () => exitOnSessionEnd("stdin end"));
|
|
421
|
+
process.stdin.on("close", () => exitOnSessionEnd("stdin close"));
|
|
422
|
+
process.stdout.on("error", (err) => {
|
|
423
|
+
if (err.code === "EPIPE")
|
|
424
|
+
exitOnSessionEnd("stdout EPIPE");
|
|
425
|
+
});
|
|
426
|
+
await server.connect(transport);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async function startHttp(port) {
|
|
430
|
+
const sharedToken = process.env.AGENT_COORD_TOKEN;
|
|
431
|
+
const bound = getTokenMap() !== null;
|
|
432
|
+
if (!bound && !sharedToken) {
|
|
433
|
+
console.error("[agent-coord-mcp] HTTP mode needs auth: either set AGENT_COORD_TOKEN (legacy " +
|
|
434
|
+
"shared bearer, advisory identity) or create ~/agent-coord/tokens.json (per-agent " +
|
|
435
|
+
"tokens, enforced identity). Refusing to start an unauthenticated network listener.");
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
if (bound && sharedToken) {
|
|
439
|
+
console.error("[agent-coord-mcp] note: tokens.json is present — AGENT_COORD_TOKEN is ignored " +
|
|
440
|
+
"(per-agent tokens take precedence).");
|
|
441
|
+
}
|
|
442
|
+
if (!bound) {
|
|
443
|
+
console.error("[agent-coord-mcp] bus identity unbound (HTTP) — shared bearer auths the channel; " +
|
|
444
|
+
"per-session identity falls back to TOFU (the first agentId/from claim becomes " +
|
|
445
|
+
"the session's bound id, can't switch mid-stream). Create ~/agent-coord/tokens.json " +
|
|
446
|
+
"to pre-bind sessions to identities at connect time.");
|
|
447
|
+
}
|
|
448
|
+
const bindAddr = process.env.AGENT_COORD_BIND ?? "127.0.0.1";
|
|
449
|
+
const sharedExpected = sharedToken ? `Bearer ${sharedToken}` : null;
|
|
450
|
+
// Fail-closed network gate. A non-loopback bind is a real network listener, so
|
|
451
|
+
// it must have (a) enforced per-agent identity and (b) a secured transport. We
|
|
452
|
+
// refuse rather than warn: a shared/advisory token lets any node impersonate
|
|
453
|
+
// any agent, and plaintext leaks bearer tokens to anyone on the path.
|
|
454
|
+
const isLoopbackBind = bindAddr === "127.0.0.1" || bindAddr === "localhost" || bindAddr === "::1";
|
|
455
|
+
if (!isLoopbackBind) {
|
|
456
|
+
if (!bound) {
|
|
457
|
+
console.error(`[agent-coord-mcp] refusing to bind ${bindAddr} without per-agent tokens: a ` +
|
|
458
|
+
`shared/advisory token lets any node impersonate any agent. Create ` +
|
|
459
|
+
`~/agent-coord/tokens.json (per-agent, enforced identity) for network binds.`);
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
if (process.env.AGENT_COORD_INSECURE !== "1") {
|
|
463
|
+
console.error(`[agent-coord-mcp] refusing plaintext bind to ${bindAddr}: bearer tokens would ` +
|
|
464
|
+
`travel in cleartext. Put the bus behind TLS or a private overlay ` +
|
|
465
|
+
`(Tailscale/WireGuard), then set AGENT_COORD_INSECURE=1 to acknowledge the ` +
|
|
466
|
+
`transport is secured out-of-band.`);
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// One transport+server pair per client session. The SDK exposes session
|
|
471
|
+
// affinity via the `mcp-session-id` header: a new request without it is
|
|
472
|
+
// an init (create new pair); follow-ups carry the id (look up the pair).
|
|
473
|
+
// We cannot share one stateful transport across clients (it errors with
|
|
474
|
+
// "Server already initialized"), and stateless mode rejects reuse.
|
|
475
|
+
const sessions = new Map();
|
|
476
|
+
// Which bound agent each session id belongs to, so a session can't be driven
|
|
477
|
+
// by a *different* bearer that merely presents its id (session hijack).
|
|
478
|
+
const sessionAgents = new Map();
|
|
479
|
+
// Session-orphan reap (docs/DECISIONS.md ADR-015 gate #2, QUEUE.md:175 part
|
|
480
|
+
// 2). Design history, kept because the rejected shape is as load-bearing as
|
|
481
|
+
// the shipped one:
|
|
482
|
+
//
|
|
483
|
+
// `transport.onclose` — the SDK's only cleanup hook — fires exclusively from
|
|
484
|
+
// an explicit DELETE (webStandardStreamableHttp.js, "Handles DELETE requests
|
|
485
|
+
// to terminate sessions"). Measured: `Client.close()` never sends one —
|
|
486
|
+
// `StreamableHTTPClientTransport.close()` only aborts locally, and the
|
|
487
|
+
// separate `terminateSession()` method that DOES send DELETE is never
|
|
488
|
+
// called anywhere in the generic client path (shared/protocol.js). So a
|
|
489
|
+
// client that reconnects the ordinary way (close + new client, the same
|
|
490
|
+
// shape as the stdio leak fixed one level up) orphans its session here
|
|
491
|
+
// forever — confirmed live: a session answered 200 after `client.close()`.
|
|
492
|
+
//
|
|
493
|
+
// FIRST DESIGN WAS IDLE-TIMEOUT, AND IT WAS WRONG — kept as the cautionary
|
|
494
|
+
// case, not deleted: the stdio sibling of this fix (#169) shipped the same
|
|
495
|
+
// shape and was HELD after a live test proved the failure mode. Killing a
|
|
496
|
+
// session's process out from under a still-attached client produces
|
|
497
|
+
// SILENT, NON-RECOVERABLE loss — no transparent respawn, confirmed by
|
|
498
|
+
// killing this repo's own coord-mcp subprocess and watching every
|
|
499
|
+
// `mcp__agent-coord__*` tool vanish with no automatic recovery. Idle is not
|
|
500
|
+
// abandoned: an agent can sit quiet for 128 minutes while working normally.
|
|
501
|
+
// A timer cannot tell those apart; it was never going to be safe as the
|
|
502
|
+
// sole signal, on the daemon or on stdio.
|
|
503
|
+
//
|
|
504
|
+
// WHAT ACTUALLY DISTINGUISHES ABANDONED FROM QUIET: whether a NEWER session
|
|
505
|
+
// for the same bound agent already exists. If it does, the OLD session's
|
|
506
|
+
// own client already reconnected — the exact shape measured live (a fresh
|
|
507
|
+
// `client.connect()` after `client.close()`) — and closing the superseded
|
|
508
|
+
// session costs that client nothing, because it already moved on. This
|
|
509
|
+
// needs no timer and no guess: `agentSessions` tracks the current session
|
|
510
|
+
// per bound agent, and a session that stops being current is provably
|
|
511
|
+
// orphaned the instant it is superseded.
|
|
512
|
+
//
|
|
513
|
+
// Unbound (TOFU/advisory) sessions have no agent identity to key
|
|
514
|
+
// supersession on, so they get none of this — deliberately: a mechanism
|
|
515
|
+
// that cannot tell "superseded" from "just another session" must not guess.
|
|
516
|
+
const agentSessions = new Map();
|
|
517
|
+
async function reapSuperseded(agentId, supersededId) {
|
|
518
|
+
const transport = sessions.get(supersededId);
|
|
519
|
+
if (!transport)
|
|
520
|
+
return; // already gone
|
|
521
|
+
console.error(`[agent-coord-mcp] reaping session ${supersededId} for agent '${agentId}' — superseded by a newer session`);
|
|
522
|
+
try {
|
|
523
|
+
await transport.close();
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
console.error(`[agent-coord-mcp] reap FAILED for session ${supersededId}:`, err);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function makeSessionTransport(boundAgent) {
|
|
530
|
+
// `let` + explicit type lets the SDK callbacks close over the binding
|
|
531
|
+
// before it's assigned — they only fire after construction completes.
|
|
532
|
+
let transport;
|
|
533
|
+
transport = new StreamableHTTPServerTransport({
|
|
534
|
+
sessionIdGenerator: () => randomUUID(),
|
|
535
|
+
onsessioninitialized: (id) => {
|
|
536
|
+
sessions.set(id, transport);
|
|
537
|
+
sessionAgents.set(id, boundAgent);
|
|
538
|
+
if (boundAgent) {
|
|
539
|
+
const superseded = agentSessions.get(boundAgent);
|
|
540
|
+
agentSessions.set(boundAgent, id);
|
|
541
|
+
if (superseded && superseded !== id) {
|
|
542
|
+
reapSuperseded(boundAgent, superseded).catch((err) => console.error("[agent-coord-mcp] supersession reap threw:", err));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
transport.onclose = () => {
|
|
548
|
+
if (transport.sessionId) {
|
|
549
|
+
sessions.delete(transport.sessionId);
|
|
550
|
+
sessionAgents.delete(transport.sessionId);
|
|
551
|
+
// Only clear the agent's CURRENT pointer if it still points here — a
|
|
552
|
+
// superseded session closing after its successor already claimed the
|
|
553
|
+
// slot must not erase the successor's own entry.
|
|
554
|
+
if (boundAgent && agentSessions.get(boundAgent) === transport.sessionId) {
|
|
555
|
+
agentSessions.delete(boundAgent);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
const server = buildServer(boundAgent);
|
|
560
|
+
await server.connect(transport);
|
|
561
|
+
return transport;
|
|
562
|
+
}
|
|
563
|
+
// Reverse-lookup: extract bearer from header, map → bound agent. Returns
|
|
564
|
+
// undefined if no map is configured (advisory mode); throws-like return of
|
|
565
|
+
// null if the bearer doesn't match any known agent (caller responds 401).
|
|
566
|
+
function resolveBoundAgent(authHeader) {
|
|
567
|
+
if (!authHeader || !authHeader.startsWith("Bearer "))
|
|
568
|
+
return { ok: false };
|
|
569
|
+
const bearer = authHeader.slice("Bearer ".length);
|
|
570
|
+
const tokenMap = getTokenMap();
|
|
571
|
+
if (tokenMap) {
|
|
572
|
+
const agent = tokenMap.get(bearer);
|
|
573
|
+
return agent ? { ok: true, agent } : { ok: false };
|
|
574
|
+
}
|
|
575
|
+
// Advisory mode: only check the shared bearer matches.
|
|
576
|
+
return sharedExpected && authHeader === sharedExpected ? { ok: true } : { ok: false };
|
|
577
|
+
}
|
|
578
|
+
const http = createServer(async (req, res) => {
|
|
579
|
+
try {
|
|
580
|
+
// Unauthenticated liveness probe so reverse proxies / orchestrators can
|
|
581
|
+
// health-check without needing a credential.
|
|
582
|
+
const url = req.url ?? "/";
|
|
583
|
+
if (req.method === "GET" && (url === "/healthz" || url === "/health")) {
|
|
584
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
585
|
+
res.end("ok\n");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// Auth gate. In bound mode the bearer also tells us *which* agent the
|
|
589
|
+
// session is bound to; in advisory mode it just gates entry. Constant-
|
|
590
|
+
// time compare isn't worthwhile here — the attacker model for the
|
|
591
|
+
// LAN/personal case is "someone on the same network" who can already
|
|
592
|
+
// observe traffic; TLS termination is the answer to that.
|
|
593
|
+
const resolved = resolveBoundAgent(req.headers.authorization);
|
|
594
|
+
if (!resolved.ok) {
|
|
595
|
+
res.writeHead(401, { "Content-Type": "text/plain", "WWW-Authenticate": "Bearer" });
|
|
596
|
+
res.end("unauthorized\n");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
// Session routing. Existing session id → reuse its transport; new client
|
|
600
|
+
// (no id, POST init) → mint a fresh transport+server pair bound to the
|
|
601
|
+
// bearer's agent; anything else is a protocol error.
|
|
602
|
+
const sid = req.headers["mcp-session-id"];
|
|
603
|
+
let transport = typeof sid === "string" ? sessions.get(sid) : undefined;
|
|
604
|
+
// Re-bind check: a session is pinned to the agent whose bearer opened it.
|
|
605
|
+
// In bound mode, reject a request whose bearer resolves to a *different*
|
|
606
|
+
// agent than the session was created for — otherwise any valid token plus
|
|
607
|
+
// a leaked session id could drive that session's identity (session hijack).
|
|
608
|
+
if (transport &&
|
|
609
|
+
getTokenMap() &&
|
|
610
|
+
typeof sid === "string" &&
|
|
611
|
+
sessionAgents.get(sid) !== resolved.agent) {
|
|
612
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
613
|
+
res.end("session/identity mismatch\n");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!transport) {
|
|
617
|
+
if (req.method !== "POST") {
|
|
618
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
619
|
+
res.end("missing or unknown mcp-session-id\n");
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
transport = await makeSessionTransport(resolved.agent);
|
|
623
|
+
}
|
|
624
|
+
await transport.handleRequest(req, res);
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
console.error("[agent-coord-mcp] http request failed:", err);
|
|
628
|
+
if (!res.headersSent) {
|
|
629
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
630
|
+
res.end("internal error\n");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
http.listen(port, bindAddr, () => {
|
|
635
|
+
const mode = bound ? `pre-bound (${getTokenMap()?.size ?? 0} agents)` : "TOFU";
|
|
636
|
+
console.error(`[agent-coord-mcp] http listening on ${bindAddr}:${port} — identity ${mode}`);
|
|
637
|
+
if (bindAddr !== "127.0.0.1" && bindAddr !== "localhost") {
|
|
638
|
+
console.error(`[agent-coord-mcp] WARNING: bound to ${bindAddr} without TLS. Front with a TLS reverse proxy ` +
|
|
639
|
+
`(or restrict to a private network e.g. Tailscale/WireGuard) before exposing publicly.`);
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
main().catch((err) => {
|
|
644
|
+
console.error("[agent-coord-mcp] fatal:", err);
|
|
645
|
+
process.exit(1);
|
|
646
|
+
});
|
|
647
|
+
//# sourceMappingURL=server.js.map
|