agent-coord-mcp 0.19.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +12 -2
  2. package/dist/build.js.map +1 -1
  3. package/dist/roles.js +10 -0
  4. package/dist/roles.js.map +1 -1
  5. package/dist/server.js +164 -42
  6. package/dist/server.js.map +1 -1
  7. package/dist/store.js +33 -1
  8. package/dist/store.js.map +1 -1
  9. package/dist/tools/admin.js +24 -5
  10. package/dist/tools/admin.js.map +1 -1
  11. package/dist/tools/registry.js +69 -1
  12. package/dist/tools/registry.js.map +1 -1
  13. package/dist/tools/render.js +1 -83
  14. package/dist/tools/render.js.map +1 -1
  15. package/dist/tools/shared.js +1 -3
  16. package/dist/tools/shared.js.map +1 -1
  17. package/dist/tools/transport.js +97 -7
  18. package/dist/tools/transport.js.map +1 -1
  19. package/dist/tools/work.js +98 -24
  20. package/dist/tools/work.js.map +1 -1
  21. package/dist/work.js +1 -259
  22. package/dist/work.js.map +1 -1
  23. package/hooks/peek-coord.mjs +0 -0
  24. package/hooks/roles.mjs +12 -0
  25. package/hooks/tier.mjs +9 -4
  26. package/hooks/tmux-pusher.mjs +16 -5
  27. package/package.json +16 -16
  28. package/scripts/check-self-dependency.mjs +62 -14
  29. package/scripts/check-test-count.mjs +3 -3
  30. package/scripts/coord-node.sh +0 -0
  31. package/scripts/coord-pusher.mjs +39 -9
  32. package/scripts/coord-token.mjs +0 -0
  33. package/scripts/spawn-agent.sh +0 -0
  34. package/scripts/stop-agent.sh +0 -0
  35. package/src/roles.ts +12 -0
  36. package/src/server.ts +181 -47
  37. package/src/store.ts +45 -1
  38. package/src/tools/admin.ts +22 -5
  39. package/src/tools/registry.ts +96 -0
  40. package/src/tools/render.ts +1 -80
  41. package/src/tools/shared.ts +18 -77
  42. package/src/tools/transport.ts +116 -6
  43. package/src/tools/work.ts +118 -30
  44. package/src/work.ts +31 -329
  45. package/dist/tools.js +0 -1852
  46. package/dist/tools.js.map +0 -1
package/src/server.ts CHANGED
@@ -4,8 +4,17 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http";
4
4
  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
- import { ensureDirs, getTokenMap, reloadTokenMapSync } from "./store.js";
7
+ import { unlinkSync, writeFileSync } from "node:fs";
8
+ import { z, type ZodRawShape } from "zod";
8
9
  import {
10
+ ensureDirs,
11
+ getTokenMap,
12
+ reloadTokenMapSync,
13
+ sessionFile,
14
+ type SessionBinding,
15
+ } from "./store.js";
16
+ import {
17
+ liveClaimEvidence,
9
18
  attachAgentSchema,
10
19
  attachAgentTool,
11
20
  clearTransportSchema,
@@ -95,8 +104,103 @@ function jsonResult(data: unknown) {
95
104
  // switching (the PR #45 spoof shape) is rejected.
96
105
  // - rename_agent updates the binding to the new id on success so the
97
106
  // renamed session keeps working.
98
- function buildServer(initialBound?: string): McpServer {
107
+ // - First-claim guard (v0.20.0): TOFU no longer lets a fresh session claim
108
+ // an id that is currently LIVE on the bus (fresh heartbeat, live
109
+ // transport, or another live bound session) — that silently created a
110
+ // second session acting as an already-running agent (hit live 2026-07-06:
111
+ // a dev session bound itself to `disavow-liaison`). A live-id claim needs
112
+ // the agent's token or an explicit force (join/register params). See
113
+ // guardFirstClaim for how absent vs unreadable evidence is decided.
114
+ // - `trackSession` (stdio only): each successful bind writes a
115
+ // sessions/<id>.<pid>.<nonce>.json marker so doctor can SEE two live
116
+ // sessions bound to one id — closure state alone cannot be inspected
117
+ // from outside the process. Not tracked for HTTP sessions: tokens.json
118
+ // already enforces their identity and many share one pid, which would
119
+ // make pid-liveness meaningless.
120
+ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {}): McpServer {
99
121
  let bound = initialBound;
122
+ const trackSession = opts.trackSession ?? false;
123
+ let sessionMarker: string | undefined;
124
+ let exitHooksInstalled = false;
125
+
126
+ // Best-effort: a marker left behind by SIGKILL has a dead pid, which both
127
+ // the guard's evidence read and doctor's duplicate-session-binding check
128
+ // treat as garbage (doctor fix deletes it).
129
+ function recordSessionBinding(agentId: string, via: string): void {
130
+ if (!trackSession) return;
131
+ try {
132
+ const file = sessionFile(agentId, process.pid, randomUUID().slice(0, 8));
133
+ const marker: SessionBinding = {
134
+ agentId,
135
+ pid: process.pid,
136
+ boundAt: Date.now(),
137
+ via,
138
+ ...(process.env.TMUX_PANE ? { tmuxPane: process.env.TMUX_PANE } : {}),
139
+ };
140
+ writeFileSync(file, JSON.stringify(marker, null, 2) + "\n");
141
+ if (sessionMarker) {
142
+ try { unlinkSync(sessionMarker); } catch { /* already gone */ }
143
+ }
144
+ sessionMarker = file;
145
+ if (!exitHooksInstalled) {
146
+ exitHooksInstalled = true;
147
+ const cleanup = () => {
148
+ try { if (sessionMarker) unlinkSync(sessionMarker); } catch { /* already gone */ }
149
+ };
150
+ process.on("exit", cleanup);
151
+ // Default signal death skips 'exit' handlers; SIGHUP stays reserved
152
+ // for the token-map reload in loadTokenMap.
153
+ for (const sig of ["SIGTERM", "SIGINT"] as const) {
154
+ process.on(sig, () => { cleanup(); process.exit(0); });
155
+ }
156
+ }
157
+ } catch { /* marker is observability, never worth failing the bind */ }
158
+ }
159
+
160
+ // Decide whether a fresh session may claim `claimed` as its identity, and
161
+ // how. Returns the bind provenance ("tofu" | "token" | "force" |
162
+ // "same-pane") or throws. Ordering is deliberate:
163
+ // - a presented token must MATCH or the claim fails loudly, even when the
164
+ // id is not live — a wrong credential silently succeeding via the
165
+ // not-live path would teach callers that garbage tokens work;
166
+ // - force is an explicit human/agent decision, honored before evidence;
167
+ // - evidence that exists but cannot be read REFUSES (cannot-verify ≠
168
+ // verified-absent; unreadable state must not disable the guard);
169
+ // - a live id refuses, except when its live pusher types into THIS
170
+ // process's own tmux pane — two sessions cannot share a pane, so that
171
+ // is the same seat restarting (the routine fleet-restart case), not a
172
+ // second session. The exception never applies when another live
173
+ // session is already bound to the id.
174
+ // - verified-not-live binds freely: refusing absent evidence would break
175
+ // every first onboarding, and the guard exists to protect LIVE ids.
176
+ async function guardFirstClaim(claimed: string, args: Record<string, unknown>): Promise<string> {
177
+ const token = typeof args["token"] === "string" ? (args["token"] as string) : undefined;
178
+ if (token !== undefined) {
179
+ if (getTokenMap()?.get(token) === claimed) return "token";
180
+ throw new Error(
181
+ `token presented for '${claimed}' does not match tokens.json (or no token map is loaded). ` +
182
+ `Mint one with scripts/coord-token.mjs add ${claimed} (then SIGHUP the bus), or pass force:true if you are certain.`,
183
+ );
184
+ }
185
+ if (args["force"] === true) return "force";
186
+ const ev = await liveClaimEvidence(claimed, Date.now());
187
+ if (!ev.verifiable) {
188
+ throw new Error(
189
+ `cannot verify whether '${claimed}' is live: ${ev.reasons.join("; ")}. ` +
190
+ `Refusing to bind rather than treating unreadable evidence as absence. ` +
191
+ `Repair the state (doctor), or pass the agent's token or force:true (join/register).`,
192
+ );
193
+ }
194
+ if (ev.live) {
195
+ if (ev.samePane && ev.boundElsewhere === 0) return "same-pane";
196
+ throw new Error(
197
+ `agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it. ` +
198
+ `If you ARE '${claimed}' restarting, re-join from its tmux pane, or pass its token or force:true (join/register). ` +
199
+ `If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
200
+ );
201
+ }
202
+ return "tofu";
203
+ }
100
204
 
101
205
  // Gate every tool that takes a caller identity. `field: null` (list_agents,
102
206
  // list_rooms, prune) bypasses the check entirely.
@@ -117,7 +221,12 @@ function buildServer(initialBound?: string): McpServer {
117
221
  if (typeof claimed === "string") {
118
222
  if (bound === undefined) {
119
223
  if (bindOnClaim) {
120
- bound = claimed; // TOFU: first claim wins, then sticky.
224
+ // TOFU: first claim wins, then sticky — but only after the
225
+ // first-claim guard agrees the id isn't someone else's live
226
+ // session (see guardFirstClaim).
227
+ const via = await guardFirstClaim(claimed, args);
228
+ bound = claimed;
229
+ recordSessionBinding(claimed, via);
121
230
  }
122
231
  } else if (bound !== claimed) {
123
232
  throw new Error(
@@ -132,12 +241,23 @@ function buildServer(initialBound?: string): McpServer {
132
241
 
133
242
  const server = new McpServer({
134
243
  name: "agent-coord",
135
- version: "0.1.0",
244
+ version: "0.23.0",
136
245
  });
137
246
 
138
- server.tool(
247
+ const addTool = (
248
+ name: string,
249
+ description: string,
250
+ inputSchema: ZodRawShape,
251
+ cb: (args: Record<string, unknown>) => Promise<ReturnType<typeof jsonResult>>,
252
+ ) => {
253
+ server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) =>
254
+ cb((args ?? {}) as Record<string, unknown>),
255
+ );
256
+ };
257
+
258
+ addTool(
139
259
  "join",
140
- "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.",
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.",
141
261
  joinSchema,
142
262
  // join explicitly sets the session binding when unset, so each agent can
143
263
  // declare its identity via join rather than relying on env vars.
@@ -145,7 +265,9 @@ function buildServer(initialBound?: string): McpServer {
145
265
  const claimed = args["agentId"];
146
266
  if (typeof claimed === "string") {
147
267
  if (bound === undefined) {
268
+ const via = await guardFirstClaim(claimed, args);
148
269
  bound = claimed;
270
+ recordSessionBinding(claimed, via);
149
271
  } else if (bound !== claimed) {
150
272
  throw new Error(
151
273
  `identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
@@ -156,147 +278,147 @@ function buildServer(initialBound?: string): McpServer {
156
278
  },
157
279
  );
158
280
 
159
- server.tool(
281
+ addTool(
160
282
  "register",
161
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.",
162
284
  registerSchema,
163
285
  gate("agentId", registerTool as (a: Record<string, unknown>) => Promise<unknown>),
164
286
  );
165
287
 
166
- server.tool(
288
+ addTool(
167
289
  "unregister",
168
290
  "Tear down this agent: detach any attached transport (kills the pusher) and remove the registry entry. Clean shutdown counterpart to `join`.",
169
291
  unregisterSchema,
170
292
  gate("agentId", unregisterTool as (a: Record<string, unknown>) => Promise<unknown>),
171
293
  );
172
294
 
173
- server.tool(
295
+ addTool(
174
296
  "quit",
175
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.",
176
298
  quitSchema,
177
299
  gate("agentId", quitTool as unknown as (a: Record<string, unknown>) => Promise<unknown>),
178
300
  );
179
301
 
180
- server.tool(
302
+ addTool(
181
303
  "status",
182
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.",
183
305
  statusSchema,
184
306
  gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
185
307
  );
186
308
 
187
- server.tool(
309
+ addTool(
188
310
  "heartbeat",
189
311
  "Refresh this agent's lastHeartbeat timestamp.",
190
312
  heartbeatSchema,
191
313
  gate("agentId", heartbeatTool as (a: Record<string, unknown>) => Promise<unknown>),
192
314
  );
193
315
 
194
- server.tool(
316
+ addTool(
195
317
  "ping",
196
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.",
197
319
  pingSchema,
198
320
  gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
199
321
  );
200
322
 
201
- server.tool(
323
+ addTool(
202
324
  "list_agents",
203
325
  "List all known agents and whether they appear online (heartbeat <5min).",
204
326
  listAgentsSchema,
205
327
  gate(null, listAgentsTool as () => Promise<unknown>),
206
328
  );
207
329
 
208
- server.tool(
330
+ addTool(
209
331
  "send_message",
210
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. The 'from' field is enforced against the session's bound identity when binding is configured.",
211
333
  sendMessageSchema,
212
334
  gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
213
335
  );
214
336
 
215
- server.tool(
337
+ addTool(
216
338
  "send_command",
217
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.",
218
340
  sendCommandSchema,
219
341
  gate("from", sendCommandTool as (a: Record<string, unknown>) => Promise<unknown>),
220
342
  );
221
343
 
222
- server.tool(
344
+ addTool(
223
345
  "read_messages",
224
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.",
225
347
  readMessagesSchema,
226
348
  gate("agentId", readMessagesTool as (a: Record<string, unknown>) => Promise<unknown>),
227
349
  );
228
350
 
229
- server.tool(
351
+ addTool(
230
352
  "retrieve_room_history",
231
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.",
232
354
  retrieveRoomHistorySchema,
233
355
  gate("agentId", retrieveRoomHistoryTool as (a: Record<string, unknown>) => Promise<unknown>),
234
356
  );
235
357
 
236
- server.tool(
358
+ addTool(
237
359
  "retrieve_message",
238
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.",
239
361
  retrieveMessageSchema,
240
362
  gate("agentId", retrieveMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
241
363
  );
242
364
 
243
- server.tool(
365
+ addTool(
244
366
  "post_status",
245
367
  "Append a status broadcast to the shared status stream.",
246
368
  postStatusSchema,
247
369
  gate("agentId", postStatusTool as (a: Record<string, unknown>) => Promise<unknown>),
248
370
  );
249
371
 
250
- server.tool(
372
+ addTool(
251
373
  "prune",
252
- "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. Pass `room` to prune a single channel, or `targets` (rooms|status|inbox|receipts|members) to narrow the sweep. 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.",
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.",
253
375
  pruneSchema,
254
376
  gate(null, pruneTool as (a: Record<string, unknown>) => Promise<unknown>),
255
377
  );
256
378
 
257
- server.tool(
379
+ addTool(
258
380
  "wait_for_message",
259
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').",
260
382
  waitForMessageSchema,
261
383
  gate("agentId", waitForMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
262
384
  );
263
385
 
264
- server.tool(
386
+ addTool(
265
387
  "list_rooms",
266
388
  "List all channels with their topic, MOTD (room rules), members, message count, and last activity.",
267
389
  listRoomsSchema,
268
390
  gate(null, listRoomsTool as () => Promise<unknown>),
269
391
  );
270
392
 
271
- server.tool(
393
+ addTool(
272
394
  "join_room",
273
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.",
274
396
  joinRoomSchema,
275
397
  gate("agentId", joinRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
276
398
  );
277
399
 
278
- server.tool(
400
+ addTool(
279
401
  "leave_room",
280
402
  "Leave a channel — removes this agent from its membership. Cannot leave the default 'general' channel.",
281
403
  leaveRoomSchema,
282
404
  gate("agentId", leaveRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
283
405
  );
284
406
 
285
- server.tool(
407
+ addTool(
286
408
  "set_room_topic",
287
409
  "Set a channel's topic (a short one-line description). Posts a system notice to the channel.",
288
410
  setRoomTopicSchema,
289
411
  gate("agentId", setRoomTopicTool as (a: Record<string, unknown>) => Promise<unknown>),
290
412
  );
291
413
 
292
- server.tool(
414
+ addTool(
293
415
  "set_room_motd",
294
416
  "Set a channel's MOTD / room rules (shown to agents on join). Posts a system notice to the channel.",
295
417
  setRoomMotdSchema,
296
418
  gate("agentId", setRoomMotdTool as (a: Record<string, unknown>) => Promise<unknown>),
297
419
  );
298
420
 
299
- server.tool(
421
+ addTool(
300
422
  "rename_agent",
301
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.",
302
424
  renameAgentSchema,
@@ -306,104 +428,116 @@ function buildServer(initialBound?: string): McpServer {
306
428
  async (args: Record<string, unknown>) => {
307
429
  const claimed = args.agentId;
308
430
  if (typeof claimed === "string") {
309
- if (bound === undefined) bound = claimed;
310
- else if (bound !== claimed) {
431
+ if (bound === undefined) {
432
+ // Renaming a live agent from a fresh session is still a first
433
+ // claim of that agent's identity — same guard as any other.
434
+ const via = await guardFirstClaim(claimed, args);
435
+ bound = claimed;
436
+ recordSessionBinding(claimed, via);
437
+ } else if (bound !== claimed) {
311
438
  throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
312
439
  }
313
440
  }
314
441
  const result = await renameAgentTool(args as { agentId: string; newAgentId: string });
315
442
  if (result && typeof result === "object" && (result as { ok?: unknown }).ok === true) {
316
443
  const to = (result as { to?: unknown }).to;
317
- if (typeof to === "string") bound = to;
444
+ if (typeof to === "string") {
445
+ bound = to;
446
+ recordSessionBinding(to, "rename");
447
+ }
318
448
  }
319
449
  return jsonResult(result);
320
450
  },
321
451
  );
322
452
 
323
- server.tool(
453
+ addTool(
324
454
  "attach_agent",
325
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.",
326
456
  attachAgentSchema,
327
457
  gate("agentId", attachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
328
458
  );
329
459
 
330
- server.tool(
460
+ addTool(
331
461
  "detach_agent",
332
462
  "Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.",
333
463
  detachAgentSchema,
334
464
  gate("agentId", detachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
335
465
  );
336
466
 
337
- server.tool(
467
+ addTool(
338
468
  "report_transport",
339
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.",
340
470
  reportTransportSchema,
341
471
  gate("agentId", reportTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
342
472
  );
343
473
 
344
- server.tool(
474
+ addTool(
345
475
  "clear_transport",
346
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.",
347
477
  clearTransportSchema,
348
478
  gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
349
479
  );
350
480
 
351
- server.tool(
481
+ addTool(
352
482
  "report_receipt",
353
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.",
354
484
  reportReceiptSchema,
355
485
  gate("agentId", reportReceiptTool as (a: Record<string, unknown>) => Promise<unknown>),
356
486
  );
357
487
 
358
- server.tool(
488
+ addTool(
359
489
  "doctor",
360
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.",
361
491
  doctorSchema,
362
492
  gate(null, doctorTool as (a: Record<string, unknown>) => Promise<unknown>),
363
493
  );
364
494
 
365
- server.tool(
495
+ addTool(
366
496
  "list_scopes",
367
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).",
368
498
  listScopesSchema,
369
499
  gate(null, listScopesTool as (a: Record<string, unknown>) => Promise<unknown>),
370
500
  );
371
501
 
372
- server.tool(
502
+ addTool(
373
503
  "import_work",
374
- "Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date} and board rows. The markdown stays authoritative — this store is a derived index, and export_work renders it back byte-identically.",
504
+ "Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md and optional docs/FACTS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date}, board rows, and facts {id,claim,verified,by,method}. The markdown stays authoritative — this store is a derived index, and export_work renders queue/done/board back byte-identically (FACTS is not an export write target).",
375
505
  importWorkSchema,
376
506
  gate(null, importWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
377
507
  );
378
508
 
379
- server.tool(
509
+ addTool(
380
510
  "list_work",
381
- "Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields, and the board's lane rows. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
511
+ "Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields, the board's lane rows, and facts. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
382
512
  listWorkSchema,
383
513
  gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
384
514
  );
385
515
 
386
- server.tool(
516
+ addTool(
387
517
  "export_work",
388
- "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.",
389
519
  exportWorkSchema,
390
520
  gate(null, exportWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
391
521
  );
392
522
 
393
- server.tool(
523
+ addTool(
394
524
  "delete_room",
395
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.",
396
526
  deleteRoomSchema,
397
527
  gate("agentId", deleteRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
398
528
  );
399
529
 
400
- server.tool(
530
+ addTool(
401
531
  "force_unregister",
402
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.",
403
533
  forceUnregisterSchema,
404
534
  gate(null, forceUnregisterTool as (a: Record<string, unknown>) => Promise<unknown>),
405
535
  );
406
536
 
537
+ // An env pre-bound session is just as live as a TOFU-bound one — record it
538
+ // so doctor's duplicate check sees it too. (No-op unless trackSession.)
539
+ if (initialBound) recordSessionBinding(initialBound, "env");
540
+
407
541
  return server;
408
542
  }
409
543
 
@@ -452,7 +586,7 @@ async function main() {
452
586
  " unregister before restarting so the new name starts fresh.",
453
587
  );
454
588
  }
455
- const server = buildServer(boundAgent);
589
+ const server = buildServer(boundAgent, { trackSession: true });
456
590
  const transport = new StdioServerTransport();
457
591
  await server.connect(transport);
458
592
  }
package/src/store.ts CHANGED
@@ -55,6 +55,37 @@ export const ARCHIVE_ROOMS_DIR = path.join(ARCHIVE_DIR, "rooms");
55
55
  export const ARCHIVE_INBOX_DIR = path.join(ARCHIVE_DIR, "inbox");
56
56
  export const ARCHIVE_STATUS_FILE = path.join(ARCHIVE_DIR, "status.jsonl");
57
57
 
58
+ // Live session-binding markers (v0.20.0). One small file per *bound* stdio MCP
59
+ // session: which agentId the session claimed, which pid holds it, and how the
60
+ // bind was established. Written at bind time, removed on clean exit; a file
61
+ // whose pid is dead is garbage doctor can clean. This is what makes two live
62
+ // sessions bound to the same id VISIBLE (doctor `duplicate-session-binding`)
63
+ // — in-process closure state can't be, by definition. HTTP sessions are not
64
+ // tracked here: with tokens.json they are already identity-enforced, and many
65
+ // share one pid, so pid-liveness would be meaningless for them.
66
+ export const SESSIONS_DIR = path.join(ROOT, "sessions");
67
+
68
+ export type SessionBinding = {
69
+ agentId: string;
70
+ pid: number;
71
+ boundAt: number;
72
+ // How the bind was established: "tofu" (first claim, id verified not live),
73
+ // "env" (AGENT_COORD_BOUND_AGENT), "token", "force", "same-pane" (live
74
+ // marker types into this session's own tmux pane), "rename".
75
+ via: string;
76
+ tmuxPane?: string;
77
+ };
78
+
79
+ export function sessionFile(agentId: string, pid: number, nonce: string): string {
80
+ return path.join(SESSIONS_DIR, `${sanitize(agentId)}.${pid}.${nonce}.json`);
81
+ }
82
+
83
+ export async function listSessionFiles(): Promise<string[]> {
84
+ if (!existsSync(SESSIONS_DIR)) return [];
85
+ const names = await fs.readdir(SESSIONS_DIR);
86
+ return names.filter((n) => n.endsWith(".json")).map((n) => path.join(SESSIONS_DIR, n));
87
+ }
88
+
58
89
  // Per-agent token map for identity-bound bus auth (v0.7.0). Shape on disk:
59
90
  // { "alice": "tk_<random-secret>", "bob": "tk_<another-secret>" }
60
91
  // HTTP transport reverse-looks-up the bearer to bind the session to an
@@ -82,7 +113,7 @@ export function workFile(project: string): string {
82
113
  }
83
114
 
84
115
  export function ensureDirs(): void {
85
- for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR]) {
116
+ for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR, SESSIONS_DIR]) {
86
117
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
87
118
  }
88
119
  for (const f of [ROOM_FILE, STATUS_FILE]) {
@@ -330,6 +361,19 @@ export async function readJson<T>(file: string, fallback: T): Promise<T> {
330
361
  }
331
362
  }
332
363
 
364
+ // Like readJson, but a file that EXISTS and cannot be parsed THROWS instead of
365
+ // silently returning the fallback. For callers whose decision flips on
366
+ // "verified absent" vs "cannot verify": the first-claim binding guard must
367
+ // refuse when evidence is unreadable, because a guard that treats unreadable
368
+ // evidence as absent is disabled by the very corruption it should be
369
+ // reporting (the absence-is-not-exemption class, #36).
370
+ export async function readJsonStrict<T>(file: string, fallback: T): Promise<T> {
371
+ if (!existsSync(file)) return fallback;
372
+ const raw = await fs.readFile(file, "utf8");
373
+ if (!raw.trim()) return fallback;
374
+ return JSON.parse(raw) as T;
375
+ }
376
+
333
377
  export async function writeJson(file: string, data: unknown): Promise<void> {
334
378
  await withLock(file, async () => {
335
379
  await fs.writeFile(file, JSON.stringify(data, null, 2), "utf8");
@@ -118,11 +118,22 @@ export async function pruneTool(args: {
118
118
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
119
119
  const decisionCutoff = Date.now() - decisionDays * 24 * 60 * 60 * 1000;
120
120
  const dryRun = args.dryRun ?? false;
121
- // room-scoped prune touches only that channel's messages; targets narrows
122
- // the sweep otherwise. Default remains "everything".
121
+ // `room` scopes every sweep to that channel; `targets` narrows which sweeps
122
+ // run. They compose: `room` only changes the DEFAULT target set, so an
123
+ // explicit `targets` always wins.
124
+ //
125
+ // WHY (regression): this previously read `scopedRoom ? ["rooms"] : args.targets`,
126
+ // which silently DISCARDED an explicit `targets` whenever `room` was passed.
127
+ // `prune {room, targets:["members"], dryRun:true}` therefore reported
128
+ // `orphanMembers: []` because the member sweep never ran — a caller asking
129
+ // "which members are phantoms in this room" got a clean bill of health that
130
+ // had not been computed, while `roomMessages` (the one target the override
131
+ // left enabled) reported real messages a live run would have archived. Field
132
+ // report: two members that `ping` called `unregistered` were invisible here.
133
+ // A sweep must never answer a question it did not evaluate.
123
134
  const scopedRoom = args.room ? normalizeRoom(args.room) : undefined;
124
135
  const targets = new Set<PruneTarget>(
125
- scopedRoom ? ["rooms"] : args.targets ?? [...PRUNE_TARGETS]
136
+ args.targets ?? (scopedRoom ? ["rooms"] : [...PRUNE_TARGETS])
126
137
  );
127
138
  const keep = (e: { ts: number; kind?: string }) => keepEntry(e, cutoff, decisionCutoff);
128
139
 
@@ -133,6 +144,10 @@ export async function pruneTool(args: {
133
144
  const staleAgent = (m: string) => !knownAgents.has(m) || (reg[m]?.lastHeartbeat ?? 0) <= cutoff;
134
145
 
135
146
  const channels = scopedRoom ? [scopedRoom] : Object.keys(await getRooms());
147
+ // The membership sweeps walk the room registry rather than `channels`, so they
148
+ // need the scope predicate explicitly — without it, `room` scoped the message
149
+ // sweep while membership silently swept EVERY room.
150
+ const inScope = (chan: string) => !scopedRoom || chan === scopedRoom;
136
151
 
137
152
  if (dryRun) {
138
153
  let roomMessages = 0;
@@ -159,6 +174,7 @@ export async function pruneTool(args: {
159
174
  if (targets.has("members")) {
160
175
  const rooms = await getRooms();
161
176
  for (const [chan, e] of Object.entries(rooms)) {
177
+ if (!inScope(chan)) continue;
162
178
  const remaining: string[] = [];
163
179
  for (const m of e.members ?? []) {
164
180
  if (!knownAgents.has(m)) orphanMembers.add(m);
@@ -264,7 +280,8 @@ export async function pruneTool(args: {
264
280
  const archivedRooms: string[] = [];
265
281
  if (targets.has("members")) {
266
282
  await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
267
- for (const e of Object.values(current)) {
283
+ for (const [chan, e] of Object.entries(current)) {
284
+ if (!inScope(chan)) continue;
268
285
  if ((e.members?.length ?? 0) === 0) continue;
269
286
  e.members = (e.members ?? []).filter((m) => {
270
287
  if (!knownAgents.has(m)) {
@@ -284,7 +301,7 @@ export async function pruneTool(args: {
284
301
  if (args.archiveEmptyRooms ?? true) {
285
302
  const rooms = await getRooms();
286
303
  for (const [chan, e] of Object.entries(rooms)) {
287
- if (chan === DEFAULT_ROOM || (e.members?.length ?? 0) > 0) continue;
304
+ if (chan === DEFAULT_ROOM || !inScope(chan) || (e.members?.length ?? 0) > 0) continue;
288
305
  const file = roomFile(chan);
289
306
  const msgs = await readJsonl<Message>(file);
290
307
  const lastTs = msgs[msgs.length - 1]?.ts ?? 0;