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
@@ -46,7 +46,10 @@ import {
46
46
  transportFile,
47
47
  TRANSPORT_DIR,
48
48
  updateJson,
49
+ listSessionFiles,
50
+ readJsonStrict,
49
51
  type RoomRegistry,
52
+ type SessionBinding,
50
53
  } from "../store.js";
51
54
  import { recordAuthorityFor, resolveRole, roleInputSchema, type RoleArg } from "../roles.js";
52
55
  import {
@@ -73,6 +76,11 @@ export const registerSchema = {
73
76
  agentId: z.string().min(1),
74
77
  project: z.string().optional(),
75
78
  role: roleInputSchema.optional(),
79
+ // First-claim guard overrides (server.ts guardFirstClaim): claiming an id
80
+ // that is LIVE on the bus refuses unless the call presents that agent's
81
+ // token (tokens.json / coord-token) or force:true. Ignored once bound.
82
+ token: z.string().optional(),
83
+ force: z.boolean().optional(),
76
84
  };
77
85
 
78
86
  // Work out what `role`/`roleId` should become, or why the update is refused.
@@ -299,6 +307,94 @@ export function isPidAlive(pid: number): boolean {
299
307
  }
300
308
  }
301
309
 
310
+ // ---------- first-claim liveness evidence ----------
311
+ // What the TOFU binding guard (server.ts guardFirstClaim) consults before a
312
+ // fresh session may claim an id. Three independent signals say "this id is
313
+ // currently active": a fresh registry heartbeat, a live transport marker, and
314
+ // a live session-binding marker from another pid. The verdict distinguishes
315
+ // VERIFIED ABSENT (state readable, id not live → free to bind; refusing here
316
+ // would break all onboarding) from CANNOT VERIFY (a state file exists but is
317
+ // unreadable → the guard must refuse rather than treat corruption as absence).
318
+ // `samePane`: a live local pusher for the claimed id types into THIS process's
319
+ // own tmux pane — two sessions cannot share a pane, so this is the same seat
320
+ // restarting in place, not a second session claiming a live id.
321
+
322
+ export type ClaimEvidence = {
323
+ live: boolean;
324
+ verifiable: boolean;
325
+ samePane: boolean;
326
+ boundElsewhere: number;
327
+ reasons: string[];
328
+ };
329
+
330
+ export async function liveClaimEvidence(agentId: string, now: number): Promise<ClaimEvidence> {
331
+ const reasons: string[] = [];
332
+ let verifiable = true;
333
+ let samePane = false;
334
+ let heartbeatFresh = false;
335
+ let markerLive = false;
336
+ let boundElsewhere = 0;
337
+
338
+ let reg: AgentRegistry = {};
339
+ try {
340
+ reg = await readJsonStrict<AgentRegistry>(AGENTS_FILE, {});
341
+ } catch {
342
+ verifiable = false;
343
+ reasons.push("agents.json exists but cannot be parsed — heartbeat liveness is unverifiable");
344
+ }
345
+ const entry = reg[agentId];
346
+ if (entry && now - entry.lastHeartbeat < STALE_MS) {
347
+ heartbeatFresh = true;
348
+ reasons.push(`fresh registry heartbeat ${Math.floor((now - entry.lastHeartbeat) / 1000)}s ago`);
349
+ }
350
+
351
+ let marker: TransportMarker | null = null;
352
+ try {
353
+ marker = await readJsonStrict<TransportMarker | null>(transportFile(agentId), null);
354
+ } catch {
355
+ verifiable = false;
356
+ reasons.push("transport marker exists but cannot be parsed — transport liveness is unverifiable");
357
+ }
358
+ if (marker && isMarkerLive(marker, reg, now)) {
359
+ markerLive = true;
360
+ reasons.push(
361
+ `live ${marker.transport} transport (pid ${marker.pid}${marker.tmuxTarget ? `, pane ${marker.tmuxTarget}` : ""})`,
362
+ );
363
+ if (
364
+ marker.transport === "tmux-push" &&
365
+ marker.tmuxTarget &&
366
+ process.env.TMUX_PANE &&
367
+ marker.tmuxTarget === process.env.TMUX_PANE
368
+ ) {
369
+ samePane = true;
370
+ }
371
+ }
372
+
373
+ for (const file of await listSessionFiles()) {
374
+ let s: SessionBinding | null = null;
375
+ try {
376
+ s = await readJsonStrict<SessionBinding | null>(file, null);
377
+ } catch {
378
+ verifiable = false;
379
+ reasons.push(`session binding ${path.basename(file)} cannot be parsed — unverifiable (doctor fix cleans it)`);
380
+ continue;
381
+ }
382
+ if (!s || s.agentId !== agentId || s.pid === process.pid) continue;
383
+ if (isPidAlive(s.pid)) {
384
+ boundElsewhere++;
385
+ reasons.push(`another live session (pid ${s.pid}, via ${s.via}) is already bound to this id`);
386
+ }
387
+ }
388
+
389
+ return {
390
+ live: heartbeatFresh || markerLive || boundElsewhere > 0,
391
+ verifiable,
392
+ samePane,
393
+ boundElsewhere,
394
+ reasons,
395
+ };
396
+ }
397
+
302
398
  // ---------- rename_agent (NICK) ----------
303
399
 
304
400
  export const renameAgentSchema = {
@@ -1,80 +1 @@
1
- // Typed record → the text layout the fleet already reads (Phase 8 Task 3.3).
2
- //
3
- // Every consumer downstream of a message — hooks/tier.mjs's prefix table, the
4
- // UI's alert parser, a human reading a tmux pane — reads `text`. Phase 8 adds
5
- // `record` alongside it, so the rendering must reproduce the byte layout those
6
- // consumers already expect. Nothing downstream changes while agents migrate.
7
- //
8
- // This is a pure function of the record: no clock, no I/O, no registry. It
9
- // never sees `from`, `to`, or anything the sender could use it to forge.
10
-
11
- import type { DecisionPayload, MessageRecord, SummaryPayload, VerdictPayload } from "./shared.js";
12
-
13
- // The case-sensitive prefixes at byte 0 of `text`, exactly as classifyTier
14
- // matches them. `decision` is absent because it renders as a multi-line block,
15
- // not a one-liner.
16
- const PREFIX: Record<string, string> = {
17
- blocker: "BLOCKER",
18
- risk: "RISK",
19
- done: "DONE",
20
- fyi: "FYI",
21
- action: "AGENT_ACTION",
22
- go: "GO",
23
- scope: "SCOPE CHANGE",
24
- // `verdict` has no prefix in the v1 vocabulary — it is new in Phase 8. A gate
25
- // PASS/FAIL was previously posted as prose, which is why verdicts could not
26
- // be routed. Rendering it under its own prefix gives it one.
27
- verdict: "VERDICT",
28
- };
29
-
30
- function isNonEmpty(v: unknown): v is string {
31
- return typeof v === "string" && v.trim().length > 0;
32
- }
33
-
34
- // The playbook's decision packet (§Decision Packet Format), byte-for-byte —
35
- // the UI parses this into a clickable decision card, and a layout that drifts
36
- // still alerts loudly but loses the card. Returns null when any of the five
37
- // fields is missing, so the caller can reject rather than emit a half packet.
38
- function renderDecision(p: DecisionPayload): string | null {
39
- if (!isNonEmpty(p.title) || !isNonEmpty(p.context)) return null;
40
- if (!isNonEmpty(p.recommendation) || !isNonEmpty(p.ifNoAction)) return null;
41
- if (!Array.isArray(p.options) || p.options.length === 0) return null;
42
- if (!p.options.every(isNonEmpty)) return null;
43
- return [
44
- `DAVID_DECISION: ${p.title}`,
45
- `Context: ${p.context}`,
46
- "Options:",
47
- ...p.options.map((o, i) => `${i + 1}. ${o}`),
48
- `Recommendation: ${p.recommendation}`,
49
- `If no action: ${p.ifNoAction}`,
50
- ].join("\n");
51
- }
52
-
53
- // `VERDICT: PASS <sha> — <notes>`. The sha is not optional in the rendering:
54
- // a verdict that doesn't name the commit it was issued against is
55
- // unfalsifiable the moment the branch moves.
56
- function renderVerdict(p: VerdictPayload): string | null {
57
- if (p.result !== "pass" && p.result !== "fail") return null;
58
- if (!isNonEmpty(p.headRefOid)) return null;
59
- const head = `${PREFIX.verdict}: ${p.result.toUpperCase()} ${p.headRefOid}`;
60
- return isNonEmpty(p.notes) ? `${head} — ${p.notes}` : head;
61
- }
62
-
63
- // Render a record to its text form, or null when the payload can't support one
64
- // (absent, or missing a field the layout needs). Null is not an error here —
65
- // the caller decides whether a record without a rendering is fatal, and it only
66
- // is when there's no author-supplied `text` to fall back on.
67
- export function renderRecord(record: MessageRecord): string | null {
68
- if (!record || typeof record.type !== "string") return null;
69
- const payload = record.payload as unknown;
70
- if (payload === undefined || payload === null) return null;
71
- if (typeof payload !== "object" || Array.isArray(payload)) return null;
72
-
73
- if (record.type === "decision") return renderDecision(payload as DecisionPayload);
74
- if (record.type === "verdict") return renderVerdict(payload as VerdictPayload);
75
-
76
- const prefix = PREFIX[record.type];
77
- if (!prefix) return null;
78
- const { summary } = payload as SummaryPayload;
79
- return isNonEmpty(summary) ? `${prefix}: ${summary}` : null;
80
- }
1
+ export { renderRecord, PREFIX } from "@davidbalzan/groundwork-seam/protocol";
@@ -1,3 +1,12 @@
1
+ import type {
2
+ Citation,
3
+ DecisionPayload,
4
+ MessageRecord,
5
+ MessageRecordType,
6
+ SummaryPayload,
7
+ SummaryRecordType,
8
+ VerdictPayload,
9
+ } from "@davidbalzan/groundwork-seam/protocol";
1
10
  import { randomUUID } from "node:crypto";
2
11
  import { existsSync, openSync, watch } from "node:fs";
3
12
  import { promises as fsp } from "node:fs";
@@ -92,82 +101,16 @@ export type TransportMarker = {
92
101
 
93
102
  export type AgentRegistry = Record<string, AgentEntry>;
94
103
 
95
- // Where a claim can be independently verified. The point is that a consumer
96
- // resolves the ref (gh, git, fs) instead of trusting the message body — a
97
- // `done` record whose PR ref doesn't exist is a false claim, not a typo.
98
- export type Citation = {
99
- kind: "pr" | "file" | "commit" | "url";
100
- ref: string;
104
+ export type {
105
+ Citation,
106
+ MessageRecordType,
107
+ DecisionPayload,
108
+ VerdictPayload,
109
+ SummaryPayload,
110
+ SummaryRecordType,
111
+ MessageRecord,
101
112
  };
102
113
 
103
- // The protocol vocabulary the fleet already speaks. Today these live as
104
- // case-sensitive prefixes at byte 0 of `text` (hooks/tier.mjs), parsed a
105
- // second time by the UI for alert priority — so a greeting before the prefix
106
- // silently downgrades a production blocker, and the two parsers can disagree
107
- // about the same message. As a field there is nothing to mis-parse.
108
- export type MessageRecordType =
109
- | "blocker" // BLOCKER: work cannot continue
110
- | "decision" // DAVID_DECISION: the human must decide
111
- | "risk" // RISK: quality/security/cost/product risk
112
- | "done" // DONE: completed work, must cite
113
- | "fyi" // FYI: no action needed
114
- | "action" // AGENT_ACTION: another agent can handle it
115
- | "go" // GO: a work order
116
- | "scope" // SCOPE CHANGE: amends a contract in flight
117
- | "verdict"; // (new) a gate PASS/FAIL — has no prefix today
118
-
119
- // The five fields of the playbook's decision packet (§Decision Packet Format).
120
- // Named to match that layout because `renderRecord` reproduces it byte-for-byte
121
- // — the UI parses the rendering into a clickable decision card.
122
- export type DecisionPayload = {
123
- title: string;
124
- context: string;
125
- options: string[];
126
- recommendation: string;
127
- ifNoAction: string;
128
- };
129
-
130
- // A gate verdict. `headRefOid` pins WHICH commit was gated: a PASS that doesn't
131
- // name the sha it was issued against is unfalsifiable once the branch moves.
132
- export type VerdictPayload = {
133
- result: "pass" | "fail";
134
- headRefOid: string;
135
- notes?: string;
136
- };
137
-
138
- // Everything else carries prose. One line, because these render as
139
- // `<PREFIX>: <summary>` and a paragraph in a tmux pane is a wall, not a status.
140
- export type SummaryPayload = { summary: string };
141
-
142
- // Message types whose payload is just a summary.
143
- export type SummaryRecordType = "blocker" | "risk" | "fyi" | "action" | "go" | "scope";
144
-
145
- // Structured counterpart to `text`, NOT a replacement: `text` is the rendering,
146
- // and a tmux pane can only receive text. A v1 agent that has never heard of
147
- // `record` omits it entirely and behaves byte-identically.
148
- //
149
- // `payload` stays OPTIONAL on every arm — Phase 8 is additive, so no new
150
- // required field may appear on the wire. What is pinned is the shape *if* a
151
- // payload is supplied: a `decision` carrying three of its five fields is
152
- // structurally wrong for the type it claims and is rejected, while a payload
153
- // with extra unknown keys passes through untouched (a v3 sender must not be
154
- // broken by a v2 server, and stripping would silently drop data on the way to
155
- // disk).
156
- //
157
- // `cites` is likewise optional here. `done` requires a PR citation, but that is
158
- // enforced in sendMessageTool as a plain {ok:false,error} rather than a schema
159
- // rejection — see the identity-binding precedent in src/server.ts.
160
- //
161
- // UNTRUSTED, exactly like `from`. A peer can claim any type here, so trust
162
- // decisions (the SCOPE countersignature, gate-runner routing) must still
163
- // resolve the sender against the registry — typed is not the same as
164
- // authenticated, and `record` must never become a path to setting `urgent`.
165
- export type MessageRecord =
166
- | { type: "decision"; payload?: DecisionPayload; cites?: Citation[] }
167
- | { type: "verdict"; payload?: VerdictPayload; cites?: Citation[] }
168
- | { type: "done"; payload?: SummaryPayload; cites?: Citation[] }
169
- | { type: SummaryRecordType; payload?: SummaryPayload; cites?: Citation[] };
170
-
171
114
  export type Message = {
172
115
  id: string;
173
116
  ts: number;
@@ -215,9 +158,7 @@ export type Message = {
215
158
  //
216
159
  // Deliberately structural about `record`: it takes anything with the two
217
160
  // fields, so status entries and archive rows can be passed without a cast.
218
- export function isDecision(e: { kind?: string; record?: { type?: string } } | null | undefined): boolean {
219
- return e?.kind === "decision" || e?.record?.type === "decision";
220
- }
161
+ export { isDecision } from "@davidbalzan/groundwork-seam/protocol";
221
162
 
222
163
  export type StatusEntry = {
223
164
  id: string;
@@ -31,7 +31,9 @@ import {
31
31
  inboxFile,
32
32
  listCursorFiles,
33
33
  listInboxFiles,
34
+ listSessionFiles,
34
35
  listTransportFiles,
36
+ type SessionBinding,
35
37
  logFile,
36
38
  memberRooms,
37
39
  normalizeRoom,
@@ -192,7 +194,21 @@ export const sendCommandSchema = {
192
194
 
193
195
  // A receipt as the pusher writes it. `submitted` is present only on control
194
196
  // receipts from a pusher new enough to VERIFY submission (v0.19.0+).
195
- type Receipt = { id: string; ts: number; control?: boolean; submitted?: boolean; verified?: boolean; reason?: string };
197
+ // `scriptMtime` is the reporting pusher's build identity — the same
198
+ // module-graph stamp the transport marker carries (newest mtime across the
199
+ // pusher's entry file AND its hooks/ imports, per #28: the stale part is as
200
+ // likely submit.mjs as the entrypoint). It is honesty, not security: a lying
201
+ // pusher defeats it, exactly like report_transport. The value is that a
202
+ // "confirmed" can be tied to the code that did the confirming.
203
+ type Receipt = {
204
+ id: string;
205
+ ts: number;
206
+ control?: boolean;
207
+ submitted?: boolean;
208
+ verified?: boolean;
209
+ reason?: string;
210
+ scriptMtime?: number;
211
+ };
196
212
 
197
213
  // Poll an agent's receipt log until a receipt for `msgId` appears or the
198
214
  // deadline passes. Returns the receipt, or null on timeout. File-only — no
@@ -221,18 +237,38 @@ async function waitForReceipt(agentId: string, msgId: string, timeoutMs: number)
221
237
  // pending with a reason the caller can act on. Reporting an unverified
222
238
  // submission as confirmed is the defect this exists to remove: a check that
223
239
  // cannot fail loudly is worse than no check.
240
+ //
241
+ // `pusherSourceMtime` (the caller passes newestPusherSourceMtime()) lets a
242
+ // CONFIRMED verdict carry a note when the reporting pusher's build identity
243
+ // is behind the on-disk pusher source, or absent entirely. The note never
244
+ // downgrades the verdict — the command demonstrably ran — it says whose
245
+ // verification logic said so. Absence of the stamp reads as UNKNOWN, never
246
+ // as fresh (same ruling as doctor's stale-pusher-script / provenance
247
+ // checks: absence is not exemption), and the absence note is issued before
248
+ // the on-disk comparison so an unstattable hooks dir cannot silence it.
224
249
  export function deliveryOutcome(
225
250
  agentId: string,
226
251
  receipt: Receipt | null,
227
252
  timeoutMs: number,
228
- ): { delivery: "confirmed" | "pending"; at?: number; reason?: string } {
253
+ pusherSourceMtime?: number,
254
+ ): { delivery: "confirmed" | "pending"; at?: number; reason?: string; note?: string } {
229
255
  if (!receipt) {
230
256
  return {
231
257
  delivery: "pending",
232
258
  reason: `no delivery receipt from '${agentId}' within ${timeoutMs}ms — the command was written but may not have reached the pane (stale/wedged pusher). Run doctor or re-attach the agent.`,
233
259
  };
234
260
  }
235
- if (receipt.submitted === true) return { delivery: "confirmed", at: receipt.ts };
261
+ if (receipt.submitted === true) {
262
+ let note: string | undefined;
263
+ if (receipt.scriptMtime === undefined) {
264
+ note = `'${agentId}' confirmed the submission, but its pusher carries no build-identity stamp — the pusher predates receipt provenance and this confirmation cannot be tied to any known code; re-attach the agent (detach_agent + attach_agent) to upgrade it.`;
265
+ } else if (pusherSourceMtime !== undefined && receipt.scriptMtime < pusherSourceMtime - 1) {
266
+ const loaded = new Date(receipt.scriptMtime).toISOString();
267
+ const ondisk = new Date(pusherSourceMtime).toISOString();
268
+ note = `'${agentId}' confirmed the submission, but its pusher loaded its code at ${loaded} and the on-disk pusher source is newer (${ondisk}) — the verification logic behind this confirmation predates the current code; re-attach the agent (detach_agent + attach_agent) to upgrade it.`;
269
+ }
270
+ return { delivery: "confirmed", at: receipt.ts, ...(note ? { note } : {}) };
271
+ }
236
272
  if (receipt.submitted === false) {
237
273
  return {
238
274
  delivery: "pending",
@@ -351,7 +387,7 @@ export async function sendCommandTool(args: {
351
387
  const wait = args.waitForDelivery ?? true;
352
388
  const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
353
389
  const receipt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
354
- const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs) : null;
390
+ const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs, newestPusherSourceMtime()) : null;
355
391
  const confirmed = outcome?.delivery === "confirmed";
356
392
  // After /clear the receiver forgets its identity and that it's bus-attached
357
393
  // (the system prompt isn't re-applied because /clear isn't a session
@@ -373,7 +409,10 @@ export async function sendCommandTool(args: {
373
409
  delivery: outcome.delivery,
374
410
  confirmed,
375
411
  ...(outcome.at !== undefined ? { deliveredAt: outcome.at } : {}),
376
- ...(confirmed ? {} : { warning: outcome.reason }),
412
+ // A confirmed delivery can still warn: the note names a reporting
413
+ // pusher whose build identity is stale or absent. `confirmed`
414
+ // stays true — the command ran; the warning is about who said so.
415
+ ...(confirmed ? (outcome.note ? { warning: outcome.note } : {}) : { warning: outcome.reason }),
377
416
  }
378
417
  : {}),
379
418
  ...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: [args.to] } } : {}),
@@ -409,11 +448,13 @@ export async function sendCommandTool(args: {
409
448
  let confirmed: string[] = [];
410
449
  let pending: string[] = [];
411
450
  let pendingReasons: string[] = [];
451
+ let confirmNotes: string[] = [];
412
452
  if (wait) {
453
+ const sourceMtime = newestPusherSourceMtime();
413
454
  const results = await Promise.all(
414
455
  delivered.map(async (m) => ({
415
456
  m,
416
- outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs),
457
+ outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs, sourceMtime),
417
458
  })),
418
459
  );
419
460
  confirmed = results.filter((r) => r.outcome.delivery === "confirmed").map((r) => r.m);
@@ -421,6 +462,9 @@ export async function sendCommandTool(args: {
421
462
  pendingReasons = results
422
463
  .filter((r) => r.outcome.delivery !== "confirmed")
423
464
  .map((r) => `${r.m}: ${r.outcome.reason}`);
465
+ confirmNotes = results
466
+ .filter((r) => r.outcome.delivery === "confirmed" && r.outcome.note)
467
+ .map((r) => r.outcome.note!);
424
468
  }
425
469
  // Same post-/clear re-anchor as the DM path — one reminder per delivered
426
470
  // member, in their own inbox, with their own agentId in the body.
@@ -444,6 +488,9 @@ export async function sendCommandTool(args: {
444
488
  warning: `not confirmed as submitted within ${deliveryTimeoutMs}ms — ${pendingReasons.join(" | ")}`,
445
489
  }
446
490
  : {}),
491
+ // Confirmed members whose reporting pusher is stale or unstamped —
492
+ // the confirmations stand, the notes say whose code issued them.
493
+ ...(confirmNotes.length ? { notes: confirmNotes } : {}),
447
494
  }
448
495
  : {}),
449
496
  ...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: delivered } } : {}),
@@ -712,6 +759,11 @@ export const joinSchema = {
712
759
  // false → never; object → attach with overrides.
713
760
  attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
714
761
  readInbox: z.boolean().optional(),
762
+ // First-claim guard overrides (server.ts guardFirstClaim): claiming an id
763
+ // that is LIVE on the bus refuses unless the call presents that agent's
764
+ // token (tokens.json / coord-token) or force:true. Ignored once bound.
765
+ token: z.string().optional(),
766
+ force: z.boolean().optional(),
715
767
  };
716
768
 
717
769
  export async function joinTool(args: {
@@ -827,6 +879,13 @@ export const reportReceiptSchema = {
827
879
  submitted: z.boolean().optional(),
828
880
  verified: z.boolean().optional(),
829
881
  reason: z.string().optional(),
882
+ // Build identity of the reporting pusher: newest mtime across its loaded
883
+ // module graph (entry file + hooks/ imports), sampled once at its startup —
884
+ // the same basis report_transport's scriptMtime uses. Absent → the receipt's
885
+ // provenance is UNKNOWN and deliveryOutcome says so; the server never
886
+ // defaults it (a default here would be assume-fresh, the twin of the
887
+ // assume-success `submitted` refuses to invent).
888
+ scriptMtime: z.number().optional(),
830
889
  };
831
890
 
832
891
  // Wire-callable counterpart to the local pusher's receipt stamp (writeReceipts
@@ -852,6 +911,7 @@ export async function reportReceiptTool(args: {
852
911
  submitted?: boolean;
853
912
  verified?: boolean;
854
913
  reason?: string;
914
+ scriptMtime?: number;
855
915
  }) {
856
916
  const receipt: Receipt & { agentId: string; from?: string } = {
857
917
  id: args.id,
@@ -862,6 +922,7 @@ export async function reportReceiptTool(args: {
862
922
  ...(args.submitted !== undefined ? { submitted: args.submitted } : {}),
863
923
  ...(args.verified !== undefined ? { verified: args.verified } : {}),
864
924
  ...(args.reason !== undefined ? { reason: args.reason } : {}),
925
+ ...(args.scriptMtime !== undefined ? { scriptMtime: args.scriptMtime } : {}),
865
926
  };
866
927
  await appendJsonl(receiptFile(args.agentId), receipt);
867
928
  return { ok: true, receipt };
@@ -1184,6 +1245,55 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1184
1245
  });
1185
1246
  }
1186
1247
 
1248
+ // 1d. Duplicate session bindings — two live MCP sessions bound to one agent
1249
+ // id means two processes are ACTING as the same agent (the
1250
+ // disavow-liaison shape: a dev session bound onto a live worker's id;
1251
+ // force/token make that possible on purpose, this makes it visible).
1252
+ // Bindings are per-process closure state, so this reads the on-disk
1253
+ // session markers stdio servers write at bind time. A marker whose pid
1254
+ // is dead is litter from a killed session (default signal death skips
1255
+ // exit handlers) — cleaned under fix. Live duplicates are NOT auto-
1256
+ // fixable: doctor cannot know which of two running sessions is the
1257
+ // impostor; the wrong session should `quit` (its marker clears on exit).
1258
+ {
1259
+ const byAgent = new Map<string, { pid: number; via: string; boundAt: number }[]>();
1260
+ const stale: string[] = [];
1261
+ for (const file of await listSessionFiles()) {
1262
+ const s = await readJson<SessionBinding | null>(file, null);
1263
+ if (!s || typeof s.pid !== "number" || !s.agentId || !isPidAlive(s.pid)) {
1264
+ stale.push(path.basename(file));
1265
+ if (fix) {
1266
+ await deleteFile(file);
1267
+ fixed.push(`deleted stale session binding ${path.basename(file)}`);
1268
+ }
1269
+ continue;
1270
+ }
1271
+ const list = byAgent.get(s.agentId) ?? [];
1272
+ list.push({ pid: s.pid, via: s.via ?? "unknown", boundAt: s.boundAt ?? 0 });
1273
+ byAgent.set(s.agentId, list);
1274
+ }
1275
+ const dupes: string[] = [];
1276
+ for (const [id, list] of byAgent) {
1277
+ if (list.length < 2) continue;
1278
+ dupes.push(
1279
+ `${id} — ${list
1280
+ .map((b) => `pid ${b.pid} (via ${b.via}, bound ${b.boundAt ? new Date(b.boundAt).toISOString() : "unknown"})`)
1281
+ .join(" AND ")}`,
1282
+ );
1283
+ }
1284
+ findings.push({
1285
+ check: "duplicate-session-binding",
1286
+ level: dupes.length ? "warn" : "ok",
1287
+ detail: dupes.length
1288
+ ? `${dupes.length} agent id(s) bound by more than one live session — two processes are acting as the same agent. Decide which is legitimate; the other should quit (its binding clears on exit).`
1289
+ : stale.length
1290
+ ? `no duplicate session bindings (${stale.length} stale binding file(s) from dead sessions${fix ? " — cleaned" : "; run doctor with fix:true to clean"})`
1291
+ : "no duplicate session bindings",
1292
+ fixable: true,
1293
+ items: dupes.length ? dupes : undefined,
1294
+ });
1295
+ }
1296
+
1187
1297
  // 2. Orphan room memberships (member not in the registry).
1188
1298
  {
1189
1299
  const orphans = new Set<string>();