agent-coord-mcp 0.18.0 → 0.19.1

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.
@@ -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;
@@ -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 = {
@@ -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 };
@@ -983,12 +1044,22 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
983
1044
  {
984
1045
  const localPusherMtime = newestPusherSourceMtime();
985
1046
  const stale: string[] = [];
1047
+ const unverifiable: string[] = [];
986
1048
  for (const fname of await listTransportFiles()) {
987
1049
  const file = path.join(TRANSPORT_DIR, fname);
988
1050
  const marker = await readJson<TransportMarker | null>(file, null);
989
1051
  if (!marker || !isMarkerLive(marker, reg, now)) continue;
990
- if (marker.transport !== "tmux-push") continue; // remote = can't verify
991
- if (marker.scriptMtime === undefined) continue; // pre-v0.8.2 marker, no info
1052
+ if (marker.transport !== "tmux-push") continue; // remote = can't verify (documented limit: can't stat another host)
1053
+ if (marker.scriptMtime === undefined) {
1054
+ // ABSENCE IS NOT EXEMPTION. The field's own writer once dropped it,
1055
+ // and the silent skip here meant the check was disabled by the very
1056
+ // thing it monitors — a live pusher we cannot verify is a warn, not
1057
+ // an ok (credit agent-coordination-david-dev). Same flip, same
1058
+ // commit, as serverBuildMtime below: the two checks must never
1059
+ // disagree about what absence means.
1060
+ unverifiable.push(`${marker.agentId} (pid ${marker.pid}, no scriptMtime stamp — cannot verify; detach_agent + attach_agent to re-stamp)`);
1061
+ continue;
1062
+ }
992
1063
  if (localPusherMtime === undefined) continue;
993
1064
  if (marker.scriptMtime < localPusherMtime - 1) { // -1ms slack for fs mtime rounding
994
1065
  const loaded = new Date(marker.scriptMtime).toISOString();
@@ -996,14 +1067,15 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
996
1067
  stale.push(`${marker.agentId} (pid ${marker.pid}, loaded ${loaded}, on-disk now ${ondisk})`);
997
1068
  }
998
1069
  }
1070
+ const bad = [...stale, ...unverifiable];
999
1071
  findings.push({
1000
1072
  check: "stale-pusher-script",
1001
- level: stale.length ? "warn" : "ok",
1002
- detail: stale.length
1003
- ? `${stale.length} attached pusher(s) running pre-upgrade code — control commands (/clear, /compact) may be silently dropped. Run detach_agent + attach_agent for each, or have the agent relaunch.`
1073
+ level: bad.length ? "warn" : "ok",
1074
+ detail: bad.length
1075
+ ? `${stale.length} attached pusher(s) running pre-upgrade code and ${unverifiable.length} whose freshness cannot be verified (no stamp) — control commands (/clear, /compact) may be silently dropped. Run detach_agent + attach_agent for each, or have the agent relaunch.`
1004
1076
  : "all attached pushers are running the current on-disk script",
1005
1077
  fixable: false,
1006
- items: stale.length ? stale : undefined,
1078
+ items: bad.length ? bad : undefined,
1007
1079
  });
1008
1080
  }
1009
1081
 
@@ -1062,17 +1134,26 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1062
1134
  {
1063
1135
  const onDisk = onDiskBuildMtime();
1064
1136
  const outdated: string[] = [];
1137
+ const unverifiable: string[] = [];
1065
1138
  for (const fname of await listTransportFiles()) {
1066
1139
  const file = path.join(TRANSPORT_DIR, fname);
1067
1140
  const marker = await readJson<TransportMarker | null>(file, null);
1068
1141
  if (!marker || !isMarkerLive(marker, reg, now)) continue;
1069
- if (marker.transport !== "tmux-push") continue; // remote = can't verify
1070
- // Absent field = pre-upgrade marker: SKIP, deliberately mirroring the
1071
- // scriptMtime semantics in 1b. Two checks holding different opinions
1072
- // about the same absence would be harder to see than either behaviour
1073
- // alone — the queued P2 ("missing scriptMtime is a skip, not a warn")
1074
- // flips BOTH together. Do not "fix" one half here.
1075
- if (marker.serverBuildMtime === undefined) continue;
1142
+ if (marker.transport !== "tmux-push") continue; // remote = can't verify (documented limit: can't stat another host)
1143
+ if (marker.serverBuildMtime === undefined) {
1144
+ // ABSENCE IS NOT EXEMPTION — flipped in the same commit as the
1145
+ // scriptMtime absence above, so the two checks can never disagree
1146
+ // about what a missing stamp means. An unstamped marker was written
1147
+ // by a pre-provenance server (or by hand): precisely the population
1148
+ // this check exists to police, and the one it must not exempt.
1149
+ // Transition is deliberately correct-and-loud: after the upgrade,
1150
+ // every pre-existing marker warns at once, each cleared by a session
1151
+ // restart + re-attach — the burst is also the only external view of
1152
+ // which sessions still run pre-provenance servers, since a stale
1153
+ // server cannot self-report (see 1b²).
1154
+ unverifiable.push(`${marker.agentId} (no serverBuildMtime stamp — stamped by a pre-provenance server; restart that session, then detach_agent + attach_agent)`);
1155
+ continue;
1156
+ }
1076
1157
  if (onDisk === undefined) continue;
1077
1158
  if (marker.serverBuildMtime < onDisk - 1) {
1078
1159
  const stamped = new Date(marker.serverBuildMtime).toISOString();
@@ -1080,14 +1161,15 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1080
1161
  outdated.push(`${marker.agentId} (stamped by server build ${stamped}, on-disk build ${current})`);
1081
1162
  }
1082
1163
  }
1164
+ const bad = [...outdated, ...unverifiable];
1083
1165
  findings.push({
1084
1166
  check: "marker-server-provenance",
1085
- level: outdated.length ? "warn" : "ok",
1086
- detail: outdated.length
1087
- ? `${outdated.length} transport marker(s) stamped by a server build older than dist/ — the stamping/spawn logic (freshness basis, pusher argv) predates the current code. Restart that agent's session, then detach_agent + attach_agent.`
1167
+ level: bad.length ? "warn" : "ok",
1168
+ detail: bad.length
1169
+ ? `${outdated.length} transport marker(s) stamped by a server build older than dist/ and ${unverifiable.length} with no provenance stamp at all — the stamping/spawn logic (freshness basis, pusher argv) predates or cannot be tied to the current code. Restart that agent's session, then detach_agent + attach_agent.`
1088
1170
  : "all local transport markers were stamped by the current server build",
1089
1171
  fixable: false,
1090
- items: outdated.length ? outdated : undefined,
1172
+ items: bad.length ? bad : undefined,
1091
1173
  });
1092
1174
  }
1093
1175
 
@@ -1163,6 +1245,55 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1163
1245
  });
1164
1246
  }
1165
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
+
1166
1297
  // 2. Orphan room memberships (member not in the registry).
1167
1298
  {
1168
1299
  const orphans = new Set<string>();
package/src/tools/work.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  parseWorkDoc,
14
14
  queueItemsOf,
15
15
  renderWorkDoc,
16
+ workDocIssues,
16
17
  } from "../work.js";
17
18
  import { loadScopes, ownsDocument } from "./scopes.js";
18
19
  import { AGENTS_FILE } from "../store.js";
@@ -110,18 +111,28 @@ export async function importWorkTool(args: { project: string; repo?: string }) {
110
111
  const state: WorkState = { project: args.project, repo, importedAt: Date.now(), docs };
111
112
  await saveState(state);
112
113
 
114
+ const allIssues = docs.flatMap((d) => workDocIssues(d.doc).map((issue) => `${d.path}: ${issue}`));
113
115
  return {
114
116
  ok: true as const,
115
117
  project: args.project,
116
118
  repo,
117
119
  file: workFile(args.project),
118
- imported: docs.map((d) => ({
119
- path: d.path,
120
- kind: d.kind,
121
- queue: queueItemsOf(d.doc).length,
122
- done: doneEntriesOf(d.doc).length,
123
- board: boardRowsOf(d.doc).length,
124
- })),
120
+ imported: docs.map((d) => {
121
+ const issues = workDocIssues(d.doc);
122
+ return {
123
+ path: d.path,
124
+ kind: d.kind,
125
+ queue: queueItemsOf(d.doc).length,
126
+ done: doneEntriesOf(d.doc).length,
127
+ board: boardRowsOf(d.doc).length,
128
+ ...(issues.length ? { issues } : {}),
129
+ };
130
+ }),
131
+ ...(allIssues.length
132
+ ? {
133
+ warning: `${allIssues.length} table row(s) were refused a record (wrong column count) and kept verbatim — the documents round-trip unchanged, but these rows are invisible to board consumers until fixed. See imported[].issues.`,
134
+ }
135
+ : {}),
125
136
  note: "the markdown remains authoritative — this store is a derived index",
126
137
  };
127
138
  }
@@ -161,10 +172,12 @@ export async function listWorkTool(args: {
161
172
  const queue: QueueItem[] = [];
162
173
  const done: DoneEntry[] = [];
163
174
  const board: BoardRow[] = [];
175
+ const issues: string[] = [];
164
176
  for (const d of state.docs) {
165
177
  queue.push(...queueItemsOf(d.doc));
166
178
  done.push(...doneEntriesOf(d.doc));
167
179
  board.push(...boardRowsOf(d.doc));
180
+ issues.push(...workDocIssues(d.doc).map((issue) => `${d.path}: ${issue}`));
168
181
  }
169
182
 
170
183
  const openQueue = args.includeDone ? queue : queue.filter((q) => !q.done);
@@ -178,6 +191,9 @@ export async function listWorkTool(args: {
178
191
  ...(args.kind === "queue" || args.kind === undefined ? { queue: filtered } : {}),
179
192
  ...(args.kind === "done" || args.kind === undefined ? { done } : {}),
180
193
  ...(args.kind === "board" || args.kind === undefined ? { board } : {}),
194
+ // A row refused for wrong arity is absent from `board` — say so rather
195
+ // than let the absence read as "that lane doesn't exist".
196
+ ...(issues.length ? { issues } : {}),
181
197
  };
182
198
  }
183
199
 
@@ -245,11 +261,16 @@ export async function exportWorkTool(args: {
245
261
  : undefined;
246
262
 
247
263
  if (write && rendered !== current) await fsp.writeFile(target, rendered, "utf8");
264
+ const issues = workDocIssues(d.doc);
248
265
  files.push({
249
266
  path: d.path,
250
267
  bytes: Buffer.byteLength(rendered, "utf8"),
251
268
  identical: rendered === current,
252
269
  written: write && rendered !== current,
270
+ // Refused rows replay verbatim — the write is byte-faithful — but a
271
+ // caller rewriting a document should hear that some rows carry no
272
+ // record, rather than infer health from `identical:true`.
273
+ ...(issues.length ? { issues } : {}),
253
274
  ...(scope ? { scope } : {}),
254
275
  });
255
276
  }