agent-coord-mcp 0.26.16 → 0.26.18

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 (79) hide show
  1. package/hooks/replay.mjs +107 -0
  2. package/hooks/tier.mjs +11 -0
  3. package/hooks/tmux-pusher.mjs +27 -0
  4. package/package.json +1 -1
  5. package/scripts/coord-pusher.mjs +16 -32
  6. package/src/server.ts +40 -20
  7. package/src/tools/admin.ts +8 -1
  8. package/src/tools/away.ts +48 -4
  9. package/src/tools/board-ref.ts +47 -2
  10. package/src/tools/messaging.ts +43 -2
  11. package/src/tools/records.ts +381 -25
  12. package/src/tools/registry.ts +5 -2
  13. package/src/tools/rooms.ts +2 -1
  14. package/src/tools/shared.ts +28 -0
  15. package/src/tools/stall.ts +29 -1
  16. package/src/tools/transport.ts +79 -10
  17. package/src/tools/work.ts +70 -4
  18. package/src/tools/worktrees.ts +64 -2
  19. package/src/work.ts +4 -0
  20. package/dist/build.js +0 -113
  21. package/dist/build.js.map +0 -1
  22. package/dist/capabilities.js +0 -158
  23. package/dist/capabilities.js.map +0 -1
  24. package/dist/prefix.js +0 -64
  25. package/dist/prefix.js.map +0 -1
  26. package/dist/roles.js +0 -132
  27. package/dist/roles.js.map +0 -1
  28. package/dist/server-identity.js +0 -82
  29. package/dist/server-identity.js.map +0 -1
  30. package/dist/server.js +0 -625
  31. package/dist/server.js.map +0 -1
  32. package/dist/store.js +0 -553
  33. package/dist/store.js.map +0 -1
  34. package/dist/tools/admin.js +0 -317
  35. package/dist/tools/admin.js.map +0 -1
  36. package/dist/tools/attention.js +0 -73
  37. package/dist/tools/attention.js.map +0 -1
  38. package/dist/tools/away.js +0 -243
  39. package/dist/tools/away.js.map +0 -1
  40. package/dist/tools/board-ref.js +0 -164
  41. package/dist/tools/board-ref.js.map +0 -1
  42. package/dist/tools/event-kinds.js +0 -39
  43. package/dist/tools/event-kinds.js.map +0 -1
  44. package/dist/tools/events.js +0 -234
  45. package/dist/tools/events.js.map +0 -1
  46. package/dist/tools/index.js +0 -14
  47. package/dist/tools/index.js.map +0 -1
  48. package/dist/tools/logwatch.js +0 -85
  49. package/dist/tools/logwatch.js.map +0 -1
  50. package/dist/tools/messaging.js +0 -667
  51. package/dist/tools/messaging.js.map +0 -1
  52. package/dist/tools/record-events.js +0 -380
  53. package/dist/tools/record-events.js.map +0 -1
  54. package/dist/tools/records.js +0 -686
  55. package/dist/tools/records.js.map +0 -1
  56. package/dist/tools/registry.js +0 -497
  57. package/dist/tools/registry.js.map +0 -1
  58. package/dist/tools/render.js +0 -2
  59. package/dist/tools/render.js.map +0 -1
  60. package/dist/tools/rooms.js +0 -210
  61. package/dist/tools/rooms.js.map +0 -1
  62. package/dist/tools/rotate.js +0 -143
  63. package/dist/tools/rotate.js.map +0 -1
  64. package/dist/tools/scopes.js +0 -126
  65. package/dist/tools/scopes.js.map +0 -1
  66. package/dist/tools/shared.js +0 -86
  67. package/dist/tools/shared.js.map +0 -1
  68. package/dist/tools/stall.js +0 -387
  69. package/dist/tools/stall.js.map +0 -1
  70. package/dist/tools/transport.js +0 -1810
  71. package/dist/tools/transport.js.map +0 -1
  72. package/dist/tools/work.js +0 -319
  73. package/dist/tools/work.js.map +0 -1
  74. package/dist/tools/worktrees.js +0 -339
  75. package/dist/tools/worktrees.js.map +0 -1
  76. package/dist/typed-records.js +0 -174
  77. package/dist/typed-records.js.map +0 -1
  78. package/dist/work.js +0 -2
  79. package/dist/work.js.map +0 -1
@@ -1,1810 +0,0 @@
1
- import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
2
- import { newestMtimeUnder, onDiskBuildMtime, onDiskSourceMtime, SERVER_BUILD_MTIME, SERVER_BUILD_SHA, BUILD_DIR } from "../build.js";
3
- import { prefixOf, prefixVerdict } from "../prefix.js";
4
- import { execFileSync } from "node:child_process";
5
- import { registerTool } from "./registry.js";
6
- import { roleInputSchema } from "../roles.js";
7
- import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
8
- // Moved to a LEAF so `status` can report capabilities without a cycle — see
9
- // src/server-identity.ts. Re-exported so every existing importer is untouched.
10
- export { resolveServerIdentity } from "../server-identity.js";
11
- import { resolveServerIdentity } from "../server-identity.js";
12
- // ONE-WAY EDGE, and the direction is the point: `status` CALLS the capabilities
13
- // verb rather than re-deriving its probes. Two probes of the same thing is the
14
- // four-matchers defect committed deliberately, which is why 17.4 was deferred
15
- // behind 13.7 instead of built beside it.
16
- import { capabilitiesTool } from "../capabilities.js";
17
- import { sendMessageTool, readMessagesTool } from "./messaging.js";
18
- import { randomUUID } from "node:crypto";
19
- import { existsSync, openSync } from "node:fs";
20
- import { promises as fsp } from "node:fs";
21
- import { spawn, spawnSync } from "node:child_process";
22
- import { fileURLToPath } from "node:url";
23
- import { z } from "zod";
24
- import path from "node:path";
25
- import { AGENTS_FILE, CURSOR_DIR, DEFAULT_ROOM, INBOX_DIR, ROOT, ROOM_FILE, ROOMS_DIR, ROOMS_FILE, STATUS_FILE, appendJsonl, cursorFile, deleteFile, ensureRoom, fileSize, getRooms, inboxFile, listCursorFiles, listInboxFiles, listSessionFiles, listTransportFiles, logFile, normalizeRoom, pidFile, readJson, readJsonl, receiptFile, rewriteJsonl, roomFile, transportFile, TRANSPORT_DIR, updateJson, } from "../store.js";
26
- import { STALE_MS, EVICT_MS, } from "./shared.js";
27
- // ---------- ping ----------
28
- export const pingSchema = {
29
- from: z.string().min(1),
30
- to: z.string().min(1),
31
- echo: z.boolean().optional(),
32
- };
33
- // Liveness probe answered entirely from server-side state — registry entry,
34
- // transport marker, pusher pid, tmux pane. It never touches the target's
35
- // session, so a fleet-wide sweep costs zero model tokens on the targets.
36
- // Distinct from `heartbeat` (the target refreshing its own activity
37
- // timestamp): ping is a third party asking "would a DM land right now?".
38
- // echo=true is the one exception — it drops a PING DM into the target's inbox
39
- // (normal delivery, so the target's model DOES wake); opt-in, default off.
40
- export async function pingTool(args) {
41
- const t0 = process.hrtime.bigint();
42
- const now = Date.now();
43
- const latencyMs = () => Math.round(Number(process.hrtime.bigint() - t0) / 1e3) / 1e3;
44
- const reg = await readJson(AGENTS_FILE, {});
45
- const entry = reg[args.to];
46
- if (!entry) {
47
- return { ok: true, to: args.to, alive: false, reachable: false, reason: "unregistered", latencyMs: latencyMs() };
48
- }
49
- const marker = await readJson(transportFile(args.to), null);
50
- const heartbeatAgeSec = Math.floor((now - entry.lastHeartbeat) / 1000);
51
- const heartbeatFresh = now - entry.lastHeartbeat < STALE_MS;
52
- let transportLive = false;
53
- let paneAlive;
54
- if (marker) {
55
- transportLive = isMarkerLive(marker, reg, now);
56
- if (transportLive && marker.transport === "tmux-push" && marker.tmuxTarget) {
57
- // The pusher can outlive its pane (agent window closed) — probe the pane.
58
- // `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
59
- // DOES NOT — tmux exits 0 for any target, including a pane killed a moment
60
- // ago, so the probe had ZERO discriminating power and reported every dead
61
- // pane alive. Pinned to the BEHAVIOUR, not a version: measured identical on
62
- // tmux 3.6b and 3.7b, and a version-pinned claim rots on the next upgrade.
63
- // Positive control, both directions: bogus target -> has-session exit 1,
64
- // display-message exit 0; live pane -> both exit 0.
65
- const probe = spawnSync("tmux", ["has-session", "-t", marker.tmuxTarget]);
66
- paneAlive = probe.status === 0;
67
- }
68
- }
69
- const reachable = transportLive && paneAlive !== false;
70
- // heartbeatFresh is only valid EVIDENCE where something writes a heartbeat.
71
- // A local tmux-push transport's liveness is its pid (isPidAlive) —
72
- // hooks/tmux-pusher.mjs never calls `heartbeat`, so the field measures time
73
- // since JOIN, not activity (Task 13.3/13.4, stall.ts's own established
74
- // rule for exactly this transport type). Crediting it here for a
75
- // tmux-push agent produced the incident this fix exists for: `alive` and
76
- // `reachable` read fully healthy, `heartbeatFresh` sat in `checks` reading
77
- // false, and the top-line boolean never surfaced the disagreement — a
78
- // status layer discarding what its own lower layer already reported, the
79
- // same shape as `stall_clock_status` before Task 13.1. A REMOTE pusher, or
80
- // an agent with no probeable marker at all, has no pid to fall back on —
81
- // there heartbeat genuinely IS the liveness mechanism, unchanged.
82
- const heartbeatIsValidSignal = !marker || marker.transport !== "tmux-push";
83
- const alive = reachable || (heartbeatIsValidSignal && heartbeatFresh);
84
- let echoSent = false;
85
- if (args.echo && alive) {
86
- await sendMessageTool({
87
- from: args.from,
88
- to: args.to,
89
- text: `PING: echo requested by ${args.from} — DM back if responsive.`,
90
- });
91
- echoSent = true;
92
- }
93
- return {
94
- ok: true,
95
- to: args.to,
96
- alive,
97
- reachable,
98
- ...(alive ? {} : { reason: marker ? "transport-dead" : "heartbeat-stale" }),
99
- checks: {
100
- registered: true,
101
- heartbeatFresh,
102
- heartbeatAgeSec,
103
- // Whether heartbeatFresh above is real evidence for this transport, or
104
- // just time-since-join. A reader who sees `heartbeatFresh: false` next
105
- // to `heartbeatValid: false` should not read that as a dissenting
106
- // signal — there is no signal there to dissent.
107
- heartbeatValid: heartbeatIsValidSignal,
108
- transport: marker?.transport ?? null,
109
- transportLive,
110
- ...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),
111
- },
112
- ...(args.echo ? { echoSent } : {}),
113
- latencyMs: latencyMs(),
114
- };
115
- }
116
- // ---------- send_command (context-management control commands) ----------
117
- // The only slash commands a lead may inject into a sub-agent's CLI. Locked on
118
- // purpose: these wipe/compact context or reload the harness's own skill files
119
- // (cheap, reversible-by-the-agent, harness-local), nothing that mutates the repo
120
- // or the bus. `reload-skills` added 2026-08-19 on a DAVID_DECISION (option 1:
121
- // that one command only; re-decide per command). Stored WITHOUT the leading
122
- // slash; the wire text is `/${cmd}`.
123
- export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"];
124
- // Transports whose pusher can actually TYPE a slash command into a live CLI.
125
- // A control command is meaningless to a plain MCP poller, so send_command is
126
- // gated to agents currently attached over one of these.
127
- const TMUX_TRANSPORTS = new Set(["tmux-push", "tmux-push-remote"]);
128
- // Normalize "clear" / "/clear" / " /Clear " → "clear"; null if not allowlisted.
129
- function normalizeControlCommand(raw) {
130
- const c = raw.trim().replace(/^\/+/, "").toLowerCase();
131
- return CONTROL_COMMANDS.includes(c) ? c : null;
132
- }
133
- // Live transports filtered to the tmux-push family (local + remote).
134
- async function liveTmuxTargets() {
135
- const all = await loadLiveTransports();
136
- const out = new Map();
137
- for (const [id, m] of all)
138
- if (TMUX_TRANSPORTS.has(m.transport))
139
- out.set(id, m);
140
- return out;
141
- }
142
- export const sendCommandSchema = {
143
- from: z.string().min(1),
144
- to: z.string().optional(),
145
- room: z.string().optional(),
146
- command: z.string().min(1),
147
- // Default 3000ms. After /clear, schedules an identity-reminder DM to each
148
- // recipient so a freshly-wiped worker re-anchors on its agentId and bus
149
- // attach state. Set 0 to opt out. Ignored for non-/clear commands.
150
- reminderMs: z.number().int().min(0).max(60_000).optional(),
151
- // Override the auto-generated reminder body if you want something specific.
152
- reminderText: z.string().optional(),
153
- // Block until the receiving pusher confirms it actually typed the command
154
- // into the pane (out-of-band receipt poll — zero added agent context).
155
- // Default true: a control command you can't confirm is the bug this fixes.
156
- // Set false for fire-and-forget. The wait is bounded by deliveryTimeoutMs.
157
- waitForDelivery: z.boolean().optional(),
158
- deliveryTimeoutMs: z.number().int().min(0).max(30_000).optional(),
159
- };
160
- // Poll an agent's receipt log until a receipt for `msgId` appears or the
161
- // deadline passes. Returns the receipt, or null on timeout. File-only — no
162
- // agent context.
163
- //
164
- // A receipt proves the pusher TYPED the payload into the pane. For a control
165
- // command that is NOT proof it ran: the command can sit in the input behind an
166
- // autocomplete menu, delivered and inert. `submitted` is the field that
167
- // distinguishes them, and `deliveryOutcome` below is the only place allowed to
168
- // turn a receipt into a "confirmed".
169
- async function waitForReceipt(agentId, msgId, timeoutMs) {
170
- const file = receiptFile(agentId);
171
- const deadline = Date.now() + timeoutMs;
172
- // First check is immediate; then poll on a short interval.
173
- for (;;) {
174
- const receipts = await readJsonl(file);
175
- const hit = receipts.find((r) => r.id === msgId);
176
- if (hit)
177
- return { ...hit, ts: hit.ts ?? Date.now() };
178
- if (Date.now() >= deadline)
179
- return null;
180
- await new Promise((res) => setTimeout(res, 150));
181
- }
182
- }
183
- // Turn a receipt (or its absence) into the delivery verdict for a CONTROL
184
- // command. Only `submitted === true` earns "confirmed" — anything else is
185
- // pending with a reason the caller can act on. Reporting an unverified
186
- // submission as confirmed is the defect this exists to remove: a check that
187
- // cannot fail loudly is worse than no check.
188
- //
189
- // `pusherSourceMtime` (the caller passes newestPusherSourceMtime()) lets a
190
- // CONFIRMED verdict carry a note when the reporting pusher's build identity
191
- // is behind the on-disk pusher source, or absent entirely. The note never
192
- // downgrades the verdict — the command demonstrably ran — it says whose
193
- // verification logic said so. Absence of the stamp reads as UNKNOWN, never
194
- // as fresh (same ruling as doctor's stale-pusher-script / provenance
195
- // checks: absence is not exemption), and the absence note is issued before
196
- // the on-disk comparison so an unstattable hooks dir cannot silence it.
197
- export function deliveryOutcome(agentId, receipt, timeoutMs, pusherSourceMtime) {
198
- if (!receipt) {
199
- return {
200
- delivery: "pending",
201
- 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.`,
202
- };
203
- }
204
- if (receipt.submitted === true) {
205
- let note;
206
- if (receipt.scriptMtime === undefined) {
207
- 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.`;
208
- }
209
- else if (pusherSourceMtime !== undefined && receipt.scriptMtime < pusherSourceMtime - 1) {
210
- const loaded = new Date(receipt.scriptMtime).toISOString();
211
- const ondisk = new Date(pusherSourceMtime).toISOString();
212
- 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.`;
213
- }
214
- return { delivery: "confirmed", at: receipt.ts, ...(note ? { note } : {}) };
215
- }
216
- if (receipt.submitted === false) {
217
- return {
218
- delivery: "pending",
219
- at: receipt.ts,
220
- reason: receipt.reason ??
221
- `'${agentId}' pasted the command but could not confirm it was submitted — it may be sitting in the input.`,
222
- };
223
- }
224
- // No `submitted` field: a pre-v0.19.0 pusher that stamps on paste. It may
225
- // well have worked — but it cannot tell us, and guessing "confirmed" is the
226
- // lie we are removing. Say what is actually known.
227
- return {
228
- delivery: "pending",
229
- at: receipt.ts,
230
- reason: `'${agentId}' typed the command into its pane, but its pusher predates submit verification and cannot confirm the command ran — re-attach the agent (detach_agent + attach_agent) to upgrade it.`,
231
- };
232
- }
233
- function defaultReminderText(agentId) {
234
- return (`[agent-coord] context reset by /clear. ` +
235
- `Your bus identity is '${agentId}'. You remain registered and attached — call ` +
236
- `status({agentId:"${agentId}"}) to re-orient (role, transport, unread) and ` +
237
- `list_rooms() for the channels you're in. Any DM or channel post you receive ` +
238
- `next is your new task context.`);
239
- }
240
- const pendingReminders = new Set();
241
- /** Reminders whose delivery FAILED, kept so a drop is observable, not stderr-only. */
242
- export const reminderFailures = [];
243
- /**
244
- * Resolves once every scheduled reminder has been delivered or has failed.
245
- *
246
- * Re-`ref`s the pending timers for the duration of the wait. The reminder timer
247
- * is deliberately `unref`d so it never holds the MCP server open on its own —
248
- * but that also means the event loop will not wait for it, and awaiting an
249
- * unref'd timer's promise hangs until the loop drains. A caller that has
250
- * explicitly asked to wait is stating the opposite intent, so the ref is
251
- * restored for exactly that window and dropped again afterwards.
252
- *
253
- * (The unref has a production consequence worth naming: if the server's
254
- * transport closes before the timer fires, the reminder is dropped. That is a
255
- * separate defect from the one this function fixes — reported, not silently
256
- * papered over.)
257
- */
258
- export function remindersSettled() {
259
- const pending = [...pendingReminders];
260
- for (const p of pending)
261
- p.timer.ref();
262
- return Promise.all(pending.map((p) => p.done)).then(() => {
263
- for (const p of pending)
264
- p.timer.unref();
265
- });
266
- }
267
- function scheduleReminders(from, recipients, delayMs, override) {
268
- // Registered BEFORE the timer fires, so awaiting settlement covers the delay
269
- // as well as the write — otherwise a caller could observe "nothing pending"
270
- // during the window between scheduling and firing.
271
- let markDone = () => { };
272
- const done = new Promise((resolve) => { markDone = resolve; });
273
- let entry;
274
- const t = setTimeout(async () => {
275
- try {
276
- for (const r of recipients) {
277
- try {
278
- const reminder = {
279
- id: randomUUID(),
280
- ts: Date.now(),
281
- from,
282
- to: r,
283
- text: override ?? defaultReminderText(r),
284
- // A just-cleared agent is contextless until this lands — it must
285
- // push immediately, never queue behind the routine tier.
286
- urgent: true,
287
- };
288
- await appendJsonl(inboxFile(r), reminder);
289
- }
290
- catch (e) {
291
- const error = e?.message ?? String(e);
292
- // Recorded, not just printed: a dropped reminder leaves a just-cleared
293
- // agent contextless, and stderr is not somewhere anyone looks for that.
294
- reminderFailures.push({ to: r, error, at: Date.now() });
295
- process.stderr.write(`[send_command] post-/clear reminder to '${r}' failed: ${error}\n`);
296
- }
297
- }
298
- }
299
- finally {
300
- pendingReminders.delete(entry);
301
- markDone();
302
- }
303
- }, delayMs);
304
- entry = { done, timer: t };
305
- pendingReminders.add(entry);
306
- // Don't keep the event loop alive solely for the reminder — the MCP server's
307
- // transport already holds it open as long as it's connected.
308
- if (typeof t.unref === "function")
309
- t.unref();
310
- }
311
- // Inject a context-management slash command into a sub-agent's live tmux
312
- // session. Writes a control-flagged message the pushers deliver RAW (no banner,
313
- // no `[DM …]` prefix) so the receiving CLI runs it as a real slash command.
314
- // Hard-gated to tmux: refuses unless the target(s) have a live tmux-push(-remote)
315
- // transport, so a command never rots unexecuted in an offline inbox.
316
- export async function sendCommandTool(args) {
317
- const cmd = normalizeControlCommand(args.command);
318
- if (!cmd) {
319
- return {
320
- ok: false,
321
- error: `unsupported command '${args.command}'. Allowed: ${CONTROL_COMMANDS.map((c) => "/" + c).join(", ")}`,
322
- };
323
- }
324
- if (!args.to && !args.room) {
325
- return { ok: false, error: "specify 'to' (a single agent) or 'room' (a channel's tmux-attached members)" };
326
- }
327
- if (args.to && args.room) {
328
- return { ok: false, error: "specify only one of 'to' or 'room'" };
329
- }
330
- const text = `/${cmd}`;
331
- const liveTmux = await liveTmuxTargets();
332
- // DM: target must itself be tmux-attached.
333
- if (args.to) {
334
- const marker = liveTmux.get(args.to);
335
- if (!marker) {
336
- return {
337
- ok: false,
338
- error: `'${args.to}' has no live tmux-push transport — control commands can only be injected into a tmux session. Attach it (join/attach_agent) or target an attached agent.`,
339
- };
340
- }
341
- const msg = {
342
- id: randomUUID(),
343
- ts: Date.now(),
344
- from: args.from,
345
- to: args.to,
346
- text,
347
- control: true,
348
- };
349
- const target = inboxFile(args.to);
350
- await appendJsonl(target, msg);
351
- // Confirm the pusher actually typed it into the pane before we report success
352
- // (unless explicitly fire-and-forget). Out-of-band receipt poll — the
353
- // confirmation rides back in THIS tool result, costing no extra agent context.
354
- const wait = args.waitForDelivery ?? true;
355
- const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
356
- const receipt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
357
- const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs, newestPusherSourceMtime()) : null;
358
- const confirmed = outcome?.delivery === "confirmed";
359
- // After /clear the receiver forgets its identity and that it's bus-attached
360
- // (the system prompt isn't re-applied because /clear isn't a session
361
- // start). Schedule a follow-up DM as a re-anchor; opt out with reminderMs:0.
362
- const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
363
- if (reminderMs > 0)
364
- scheduleReminders(args.from, [args.to], reminderMs, args.reminderText);
365
- return {
366
- ok: true,
367
- id: msg.id,
368
- command: text,
369
- target,
370
- delivered: [args.to],
371
- transport: marker.transport,
372
- // delivery: confirmed = pusher typed it into the pane; pending = written but
373
- // unconfirmed within the timeout (stale/wedged pusher — run doctor). Absent
374
- // when waitForDelivery:false.
375
- ...(wait && outcome
376
- ? {
377
- delivery: outcome.delivery,
378
- confirmed,
379
- ...(outcome.at !== undefined ? { deliveredAt: outcome.at } : {}),
380
- // A confirmed delivery can still warn: the note names a reporting
381
- // pusher whose build identity is stale or absent. `confirmed`
382
- // stays true — the command ran; the warning is about who said so.
383
- ...(confirmed ? (outcome.note ? { warning: outcome.note } : {}) : { warning: outcome.reason }),
384
- }
385
- : {}),
386
- ...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: [args.to] } } : {}),
387
- };
388
- }
389
- // Room: broadcast to every tmux-attached member (never the sender itself).
390
- const chan = normalizeRoom(args.room);
391
- const rooms = await getRooms();
392
- const members = rooms[chan]?.members ?? [];
393
- const delivered = members.filter((m) => m !== args.from && liveTmux.has(m));
394
- if (delivered.length === 0) {
395
- return {
396
- ok: false,
397
- error: `no tmux-attached members in #${chan} to receive '${text}' (${members.length} member(s) total). Control commands only fire in a live tmux session.`,
398
- };
399
- }
400
- const skipped = members.filter((m) => m !== args.from && !liveTmux.has(m));
401
- const msg = {
402
- id: randomUUID(),
403
- ts: Date.now(),
404
- from: args.from,
405
- room: chan,
406
- text,
407
- control: true,
408
- };
409
- const target = roomFile(chan);
410
- await appendJsonl(target, msg);
411
- // Confirm each member's pusher typed it in (same msg.id lands in every
412
- // member's own receipt file). Poll all in parallel within one timeout.
413
- const wait = args.waitForDelivery ?? true;
414
- const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
415
- let confirmed = [];
416
- let pending = [];
417
- let pendingReasons = [];
418
- let confirmNotes = [];
419
- if (wait) {
420
- const sourceMtime = newestPusherSourceMtime();
421
- const results = await Promise.all(delivered.map(async (m) => ({
422
- m,
423
- outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs, sourceMtime),
424
- })));
425
- confirmed = results.filter((r) => r.outcome.delivery === "confirmed").map((r) => r.m);
426
- pending = results.filter((r) => r.outcome.delivery !== "confirmed").map((r) => r.m);
427
- pendingReasons = results
428
- .filter((r) => r.outcome.delivery !== "confirmed")
429
- .map((r) => `${r.m}: ${r.outcome.reason}`);
430
- confirmNotes = results
431
- .filter((r) => r.outcome.delivery === "confirmed" && r.outcome.note)
432
- .map((r) => r.outcome.note);
433
- }
434
- // Same post-/clear re-anchor as the DM path — one reminder per delivered
435
- // member, in their own inbox, with their own agentId in the body.
436
- const reminderMs = cmd === "clear" ? args.reminderMs ?? 3000 : 0;
437
- if (reminderMs > 0)
438
- scheduleReminders(args.from, delivered, reminderMs, args.reminderText);
439
- return {
440
- ok: true,
441
- id: msg.id,
442
- command: text,
443
- target,
444
- room: chan,
445
- delivered,
446
- skipped: skipped.length ? skipped : undefined,
447
- ...(wait
448
- ? {
449
- delivery: pending.length === 0 ? "confirmed" : "partial",
450
- confirmed,
451
- ...(pending.length
452
- ? {
453
- pending,
454
- warning: `not confirmed as submitted within ${deliveryTimeoutMs}ms — ${pendingReasons.join(" | ")}`,
455
- }
456
- : {}),
457
- // Confirmed members whose reporting pusher is stale or unstamped —
458
- // the confirmations stand, the notes say whose code issued them.
459
- ...(confirmNotes.length ? { notes: confirmNotes } : {}),
460
- }
461
- : {}),
462
- ...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: delivered } } : {}),
463
- };
464
- }
465
- // Does `pid` actually belong to one of our tmux pushers? A transport marker
466
- // records a pid, but a marker can outlive its process and pids get recycled —
467
- // so "pid is alive" is NOT evidence the pid is still the pusher. Anything that
468
- // SIGTERMs a marker's pid must confirm identity first or it will eventually
469
- // kill an unrelated process on the user's machine.
470
- //
471
- // Pushers are spawned as `<node> <.../hooks/tmux-pusher.mjs>` (see
472
- // attachAgentTool), so the script path in the process's argv is the signature.
473
- // Returns false when we cannot confirm — including when `ps` is unavailable.
474
- // Refusing to kill an unverifiable pid is the safe failure: a wedged pusher
475
- // that survives is a nuisance, a wrong SIGTERM is not.
476
- export function isPusherProcess(pid) {
477
- if (!Number.isInteger(pid) || pid <= 0)
478
- return false;
479
- const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" });
480
- if (ps.status !== 0)
481
- return false; // pid gone, or no usable ps
482
- return (ps.stdout ?? "").includes(path.basename(resolvePusherPath()));
483
- }
484
- // ---------- attach_agent / detach_agent (tmux push transport) ----------
485
- export const attachAgentSchema = {
486
- agentId: z.string().min(1),
487
- tmuxTarget: z.string().optional(),
488
- includeRoom: z.boolean().optional(),
489
- allowlist: z.array(z.string()).optional(),
490
- debounceMs: z.number().int().positive().max(60_000).optional(),
491
- };
492
- export async function attachAgentTool(args) {
493
- // Resolve target: explicit arg > MCP server's own TMUX_PANE env.
494
- const target = args.tmuxTarget ?? process.env.TMUX_PANE;
495
- if (!target) {
496
- return {
497
- ok: false,
498
- error: "tmuxTarget not provided and the MCP server is not running inside tmux (no $TMUX_PANE). Pass tmuxTarget explicitly (e.g. '%42' or 'session:window.pane').",
499
- };
500
- }
501
- // Validate target exists.
502
- // `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
503
- // DOES NOT — tmux exits 0 for any target, including a pane killed a moment
504
- // ago, so the probe had ZERO discriminating power and reported every dead
505
- // pane alive. Pinned to the BEHAVIOUR, not a version: measured identical on
506
- // tmux 3.6b and 3.7b, and a version-pinned claim rots on the next upgrade.
507
- // Positive control, both directions: bogus target -> has-session exit 1,
508
- // display-message exit 0; live pane -> both exit 0.
509
- const probe = spawnSync("tmux", ["has-session", "-t", target]);
510
- if (probe.status !== 0) {
511
- return {
512
- ok: false,
513
- error: `tmux target '${target}' not found: ${(probe.stderr ?? "").toString().trim()}`,
514
- };
515
- }
516
- // If something's already attached, refuse rather than spawn a second pusher.
517
- const existing = await readJson(transportFile(args.agentId), null);
518
- if (existing && isPidAlive(existing.pid)) {
519
- return {
520
- ok: false,
521
- error: `agent '${args.agentId}' already has a live ${existing.transport} attached (pid ${existing.pid}). Call detach_agent first.`,
522
- existing,
523
- };
524
- }
525
- // Clean up dead marker, if any.
526
- if (existing)
527
- await deleteFile(transportFile(args.agentId));
528
- const pusher = resolvePusherPath();
529
- if (!existsSync(pusher)) {
530
- return { ok: false, error: `tmux-pusher not found at ${pusher}` };
531
- }
532
- // Detached spawn so the pusher outlives this MCP request/process.
533
- const log = logFile(args.agentId, "pusher");
534
- await fsp.mkdir(path.dirname(log), { recursive: true });
535
- await fsp.mkdir(path.dirname(pidFile(args.agentId, "pusher")), { recursive: true });
536
- await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
537
- const logFd = openSync(log, "a");
538
- // Default: deliver room broadcasts too. The bus is chat-first — silence on
539
- // a room post is a worse failure mode than a slightly noisier pane. Callers
540
- // who want DM-only can pass includeRoom:false explicitly.
541
- const includeRoom = args.includeRoom !== false;
542
- // Use the exact node binary running this server, not bare "node" — the MCP
543
- // server is often launched via an absolute path (nvm/Homebrew/bundled
544
- // runtime) that isn't on the spawned child's PATH, which would silently fail
545
- // the pusher launch ("attached but nothing arrives").
546
- // `--agent <id>` is inert to the pusher (env stays authoritative) but puts
547
- // the agentId in argv, so a pattern kill can be scoped to ONE pusher
548
- // (`pkill -f "tmux-pusher.mjs --agent <id>"`). Without it the only matchable
549
- // pattern was the script path, and a `pkill -f tmux-pusher.mjs` during one
550
- // agent's cleanup silently detached every live agent on the bus (2026-07-28).
551
- const child = spawn(process.execPath, [pusher, "--agent", args.agentId], {
552
- detached: true,
553
- stdio: ["ignore", logFd, logFd],
554
- env: {
555
- ...process.env,
556
- AGENT_COORD_ID: args.agentId,
557
- AGENT_COORD_TMUX_TARGET: target,
558
- ...(includeRoom ? { AGENT_COORD_INCLUDE_ROOM: "1" } : {}),
559
- ...(args.allowlist && args.allowlist.length > 0
560
- ? { AGENT_COORD_ALLOWLIST: args.allowlist.join(",") }
561
- : {}),
562
- ...(args.debounceMs ? { AGENT_COORD_DEBOUNCE_MS: String(args.debounceMs) } : {}),
563
- },
564
- });
565
- child.unref();
566
- const pid = child.pid;
567
- if (!pid)
568
- return { ok: false, error: "spawn returned no pid" };
569
- // Write pid file (for scripts) and transport marker (for list_agents).
570
- await fsp.writeFile(pidFile(args.agentId, "pusher"), String(pid), "utf8");
571
- // Stamp the pusher source's freshness so doctor() can flag a stale daemon if
572
- // it outlives a later upgrade of the on-disk code (see v0.8.1 → v0.8.2 bug
573
- // report: control commands silently dropped by pre-v0.8 in-memory code).
574
- const scriptMtime = newestPusherSourceMtime();
575
- const marker = {
576
- agentId: args.agentId,
577
- transport: "tmux-push",
578
- pid,
579
- tmuxTarget: target,
580
- since: Date.now(),
581
- scriptMtime,
582
- // Provenance: the build identity THIS server loaded at startup — not a
583
- // fresh stat of dist/, because the code doing the stamping is the loaded
584
- // code, and after an in-place rebuild the two differ (that difference is
585
- // exactly what doctor's provenance check exists to surface).
586
- serverBuildMtime: SERVER_BUILD_MTIME,
587
- // WHAT this transport carries, recorded by the code that decides it.
588
- // `includeRoom` is what the pusher is actually spawned with a few lines
589
- // above, so the marker cannot claim a capability the process was not
590
- // given — the marker and the spawn come from one value, not two.
591
- rooms: includeRoom,
592
- };
593
- // Use updateJson so it lockfile-protects and creates the file atomically.
594
- await updateJson(transportFile(args.agentId), marker, () => marker);
595
- // Best-effort scan for a peek-coord.mjs hook wired to the same agentId —
596
- // both consumers share the cursor file and would race / double-deliver.
597
- const conflictingHook = await detectPeekCoordHook(args.agentId);
598
- return {
599
- ok: true,
600
- agentId: args.agentId,
601
- transport: "tmux-push",
602
- tmuxTarget: target,
603
- pid,
604
- log,
605
- ...(conflictingHook
606
- ? {
607
- warnings: [
608
- `peek-coord.mjs hook for agentId='${args.agentId}' detected in ${conflictingHook}. ` +
609
- `Running both transports causes double-delivery — disable one. ` +
610
- `Recommend removing the peek-coord hook entry since tmux-push supersedes it.`,
611
- ],
612
- }
613
- : {}),
614
- };
615
- }
616
- async function detectPeekCoordHook(agentId) {
617
- const home = process.env.HOME ?? "";
618
- const cwd = process.cwd();
619
- const candidates = [
620
- path.join(home, ".claude", "settings.json"),
621
- path.join(home, ".claude", "settings.local.json"),
622
- path.join(cwd, ".claude", "settings.json"),
623
- path.join(cwd, ".claude", "settings.local.json"),
624
- ];
625
- for (const file of candidates) {
626
- if (!existsSync(file))
627
- continue;
628
- try {
629
- const raw = await fsp.readFile(file, "utf8");
630
- if (raw.includes("peek-coord.mjs") && raw.includes(`AGENT_COORD_ID=${agentId}`)) {
631
- return file;
632
- }
633
- }
634
- catch {
635
- // unreadable, skip
636
- }
637
- }
638
- return undefined;
639
- }
640
- export const detachAgentSchema = {
641
- agentId: z.string().min(1),
642
- };
643
- export async function detachAgentTool(args) {
644
- const marker = await readJson(transportFile(args.agentId), null);
645
- let killed = false;
646
- let unverified = false;
647
- if (marker && isPidAlive(marker.pid)) {
648
- // ALIVE IS NOT ENOUGH — VERIFY IT IS A PUSHER BEFORE SIGNALLING IT.
649
- //
650
- // A pid is only meaningful while the process that owned it is running: pids
651
- // are recycled, and a stale marker pointing at a recycled pid makes this a
652
- // SIGTERM at an unrelated process. Doctor's reaper already gates its kill on
653
- // `isPusherProcess` for exactly this reason (see the wedged-pusher fix); this
654
- // path never took the correction, and `unregister`/`quit` inherit it.
655
- //
656
- // UNVERIFIABLE MEANS CLEAR THE MARKER, NEVER SIGNAL. The marker is ours to
657
- // delete and the process is not ours to kill — those are different
658
- // authorities, and conflating them is how a detach becomes someone else's
659
- // outage. Reported rather than silent, so a pusher that genuinely needs
660
- // killing is not left running unnoticed.
661
- if (isPusherProcess(marker.pid)) {
662
- try {
663
- process.kill(marker.pid, "SIGTERM");
664
- killed = true;
665
- }
666
- catch {
667
- // already gone
668
- }
669
- }
670
- else {
671
- unverified = true;
672
- }
673
- }
674
- await deleteFile(transportFile(args.agentId));
675
- await deleteFile(pidFile(args.agentId, "pusher"));
676
- return {
677
- ok: true,
678
- agentId: args.agentId,
679
- killed,
680
- hadMarker: marker !== null,
681
- ...(unverified
682
- ? {
683
- warning: `pid ${marker.pid} is alive but is NOT a pusher process — the marker was cleared and NOTHING was signalled. ` +
684
- `A stale marker pointing at a recycled pid would otherwise make this a SIGTERM at an unrelated process. ` +
685
- `If a pusher is still running for '${args.agentId}', find it with \`doctor\` and let its reaper handle it.`,
686
- }
687
- : {}),
688
- };
689
- }
690
- function resolvePusherPath() {
691
- // transport.js (compiled) lives in dist/tools/; pusher lives in hooks/ at repo root.
692
- const here = path.dirname(fileURLToPath(import.meta.url));
693
- return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
694
- }
695
- /** An epoch-ms stamp as ISO-8601 UTC, or an explicit absence — never a blank. */
696
- function stamp(ms) {
697
- return ms === undefined ? "(no stamp)" : new Date(ms).toISOString();
698
- }
699
- // Freshness basis for the stale-pusher-script mechanism: the newest mtime
700
- // across the pusher's source dir (hooks/*.mjs), NOT just the entry file. The
701
- // pusher imports submit.mjs / tier.mjs / roles.mjs, so a fix touching only an
702
- // import (the #21/#25 control-submit fixes did) leaves tmux-pusher.mjs's own
703
- // mtime unchanged — a single-file stamp/compare reports ok on a pusher running
704
- // exactly the code the fix replaced. Used by both the attach-time stamp and
705
- // doctor's on-disk comparison so the two sides can never drift apart.
706
- // AGENT_COORD_HOOKS_DIR is a test seam only: it redirects what freshness
707
- // MEASURES (against a temp copy of hooks/) so tests never touch the mtimes of
708
- // real sources shared with live pushers — it never changes what attach SPAWNS.
709
- function newestPusherSourceMtime() {
710
- const dir = process.env.AGENT_COORD_HOOKS_DIR ?? path.dirname(resolvePusherPath());
711
- return newestMtimeUnder(dir, [".mjs"]);
712
- }
713
- // ---------- status / whoami ----------
714
- export const statusSchema = { agentId: z.string().min(1) };
715
- export async function statusTool(args) {
716
- const reg = await readJson(AGENTS_FILE, {});
717
- const entry = reg[args.agentId];
718
- const transports = await loadLiveTransports();
719
- const transport = transports.get(args.agentId);
720
- const inbox = await readJsonl(inboxFile(args.agentId));
721
- const cursor = await readJson(cursorFile(args.agentId), {});
722
- const inboxOffset = cursor.inboxOffset ?? 0;
723
- const unread = Math.max(0, inbox.length - inboxOffset);
724
- // WHAT THIS PROCESS CAN ACTUALLY DO, embedded WHOLE.
725
- //
726
- // A new tool otherwise lands silently: an agent reading an old listing
727
- // asserts the tool does not exist, and it is right about its own listing and
728
- // wrong about the world. Three wrong conclusions in two days came from
729
- // exactly that, each about which build a pane was running.
730
- //
731
- // The full report travels, not a summary. Flattening it to a boolean would
732
- // hand the caller a tidier answer and a worse one: `versionLabel` stays
733
- // CONTEXT rather than evidence, a THROWN probe stays an absent capability
734
- // rather than a broken check, and `answeredBy` keeps naming which pid
735
- // answered — two servers disagreeing mid-rollout is the normal case, and it
736
- // is the case every other instrument here reports as if the fleet were one
737
- // thing.
738
- const capabilities = await capabilitiesTool();
739
- return {
740
- agentId: args.agentId,
741
- registered: !!entry,
742
- entry,
743
- attached: !!transport,
744
- transport,
745
- inboxDepth: inbox.length,
746
- inboxUnread: unread,
747
- inTmux: !!process.env.TMUX_PANE,
748
- tmuxPane: process.env.TMUX_PANE,
749
- capabilities,
750
- };
751
- }
752
- // ---------- join (combo: register + auto-attach + read inbox) ----------
753
- const joinAttachOptionsSchema = z.object({
754
- tmuxTarget: z.string().optional(),
755
- includeRoom: z.boolean().optional(),
756
- allowlist: z.array(z.string()).optional(),
757
- debounceMs: z.number().int().positive().max(60_000).optional(),
758
- });
759
- export const joinSchema = {
760
- agentId: z.string().min(1),
761
- project: z.string().optional(),
762
- // Free text, or a declared identity ({roleId, displayName}) — see
763
- // roleInputSchema. A frozen roleId cannot be changed by re-joining.
764
- role: roleInputSchema.optional(),
765
- // attach: undefined → auto-attach if $TMUX_PANE is set; true → always try;
766
- // false → never; object → attach with overrides.
767
- attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
768
- readInbox: z.boolean().optional(),
769
- // First-claim guard overrides (server.ts guardFirstClaim): claiming an id
770
- // that is LIVE on the bus refuses unless the call presents that agent's
771
- // token (tokens.json / coord-token) or force:true. Ignored once bound.
772
- token: z.string().optional(),
773
- force: z.boolean().optional(),
774
- // Prose-only exemption from the typed-record rule — see registerSchema.
775
- // Declared here too because `join` is the call every card actually makes.
776
- proseOnly: z.union([z.boolean(), z.object({ reason: z.string().min(1) })]).optional(),
777
- };
778
- export async function joinTool(args) {
779
- const reg = await registerTool({
780
- agentId: args.agentId,
781
- project: args.project,
782
- role: args.role,
783
- proseOnly: args.proseOnly,
784
- });
785
- // A refused role update (frozen roleId) fails the whole join rather than
786
- // silently attaching a transport under the wrong identity.
787
- if (!reg.ok)
788
- return reg;
789
- // Decide attach behavior.
790
- const wantAttach = args.attach === false
791
- ? false
792
- : args.attach === true || typeof args.attach === "object"
793
- ? true
794
- : !!process.env.TMUX_PANE; // undefined → auto-detect
795
- // Always present as object | null so callers can branch on a single key
796
- // instead of "did I pass attach?" — per agent-pa's API review.
797
- let attach = null;
798
- if (wantAttach) {
799
- const opts = typeof args.attach === "object" ? args.attach : {};
800
- attach = await attachAgentTool({ agentId: args.agentId, ...opts });
801
- }
802
- const readInbox = args.readInbox ?? true;
803
- let inbox = null;
804
- if (readInbox) {
805
- inbox = await readMessagesTool({ agentId: args.agentId, source: "inbox" });
806
- }
807
- // Surface the default channel's topic + MOTD (room rules) in the same
808
- // round-trip, so a connecting agent sees them without a separate call.
809
- const rooms = await getRooms();
810
- const def = rooms[DEFAULT_ROOM];
811
- return {
812
- ok: true,
813
- registered: reg.agent,
814
- attached: !!attach && attach.ok !== false,
815
- attach,
816
- inbox,
817
- defaultRoom: { room: DEFAULT_ROOM, topic: def?.topic, motd: def?.motd },
818
- inTmux: !!process.env.TMUX_PANE,
819
- };
820
- }
821
- // ---------- transport markers (for remote pushers) ----------
822
- export const reportTransportSchema = {
823
- agentId: z.string().min(1),
824
- transport: z.string().min(1),
825
- tmuxTarget: z.string().optional(),
826
- host: z.string().optional(),
827
- since: z.number().optional(),
828
- // mtime of the script the remote daemon loaded into memory at spawn time
829
- // (epoch ms). Lets doctor() flag the remote pusher as stale if its on-disk
830
- // counterpart has been upgraded since. The remote pusher passes
831
- // `(await fsp.stat(__filename)).mtimeMs`; absent → doctor skips the check.
832
- scriptMtime: z.number().optional(),
833
- // Whether this pusher carries room traffic as well as DMs. Absent → the
834
- // marker cannot say, and every reader reports UNKNOWN rather than assuming
835
- // a full transport (the worker-2 shape).
836
- rooms: z.boolean().optional(),
837
- };
838
- // Called by an external push daemon (typically scripts/coord-pusher.mjs on a
839
- // remote machine) to publish a transport marker so list_agents reflects the
840
- // attachment. The local tmux-push path writes the marker directly inside
841
- // attach_agent; this is the wire-callable equivalent for remote pushers.
842
- export async function reportTransportTool(args) {
843
- const marker = {
844
- agentId: args.agentId,
845
- transport: args.transport,
846
- pid: 0, // not meaningful for remote; liveness comes from heartbeat
847
- tmuxTarget: args.tmuxTarget,
848
- host: args.host,
849
- since: args.since ?? Date.now(),
850
- scriptMtime: args.scriptMtime,
851
- rooms: args.rooms,
852
- };
853
- await updateJson(transportFile(args.agentId), marker, () => marker);
854
- return { ok: true, marker };
855
- }
856
- export const clearTransportSchema = {
857
- agentId: z.string().min(1),
858
- };
859
- // Idempotent remote-counterpart to detach_agent: just deletes the marker. Used
860
- // by the remote pusher on graceful shutdown so list_agents stops showing it
861
- // attached. (Does NOT try to kill any process — there's nothing local to kill.)
862
- export async function clearTransportTool(args) {
863
- const removed = await deleteFile(transportFile(args.agentId));
864
- return { ok: true, removed };
865
- }
866
- export const reportReceiptSchema = {
867
- agentId: z.string().min(1),
868
- id: z.string().min(1),
869
- from: z.string().optional(),
870
- control: z.boolean().optional(),
871
- submitted: z.boolean().optional(),
872
- verified: z.boolean().optional(),
873
- reason: z.string().optional(),
874
- // Build identity of the reporting pusher: newest mtime across its loaded
875
- // module graph (entry file + hooks/ imports), sampled once at its startup —
876
- // the same basis report_transport's scriptMtime uses. Absent → the receipt's
877
- // provenance is UNKNOWN and deliveryOutcome says so; the server never
878
- // defaults it (a default here would be assume-fresh, the twin of the
879
- // assume-success `submitted` refuses to invent).
880
- scriptMtime: z.number().optional(),
881
- };
882
- // Wire-callable counterpart to the local pusher's receipt stamp (writeReceipts
883
- // in hooks/tmux-pusher.mjs). A remote pusher types into a pane on ANOTHER
884
- // machine and cannot append to this host's receipts/<id>.jsonl, so before this
885
- // existed a control command to a tmux-push-remote agent was never confirmable:
886
- // send_command waited out deliveryTimeoutMs and reported delivery:"pending"
887
- // even when the command demonstrably ran.
888
- //
889
- // The receipt is appended in the exact shape the local pusher writes, so
890
- // waitForReceipt/deliveryOutcome need no remote-specific branch. `submitted`
891
- // is recorded only when the caller reports it — absence means "typed but
892
- // unverified", which deliveryOutcome refuses to call confirmed. The server
893
- // cannot see the remote pane, so it stores what the pusher observed and
894
- // nothing more; defaulting the field here would recreate assume-success one
895
- // layer up. Trust matches report_transport: the identity gate binds agentId
896
- // to the session, so a pusher can only stamp its own agent's receipt file.
897
- export async function reportReceiptTool(args) {
898
- const receipt = {
899
- id: args.id,
900
- agentId: args.agentId,
901
- ts: Date.now(),
902
- ...(args.from !== undefined ? { from: args.from } : {}),
903
- control: args.control === true,
904
- ...(args.submitted !== undefined ? { submitted: args.submitted } : {}),
905
- ...(args.verified !== undefined ? { verified: args.verified } : {}),
906
- ...(args.reason !== undefined ? { reason: args.reason } : {}),
907
- ...(args.scriptMtime !== undefined ? { scriptMtime: args.scriptMtime } : {}),
908
- };
909
- await appendJsonl(receiptFile(args.agentId), receipt);
910
- return { ok: true, receipt };
911
- }
912
- // Count non-empty lines vs successfully-parsed entries in a JSONL file.
913
- // Offsets index the PARSED entries (see readJsonl), so `parsed` is the figure
914
- // cursor math is compared against; `malformed` is the desync risk.
915
- async function scanJsonl(file) {
916
- if (!existsSync(file))
917
- return { lines: 0, parsed: 0, malformed: 0 };
918
- const raw = await fsp.readFile(file, "utf8");
919
- let lines = 0;
920
- let parsed = 0;
921
- for (const line of raw.split("\n")) {
922
- if (!line.trim())
923
- continue;
924
- lines++;
925
- try {
926
- JSON.parse(line);
927
- parsed++;
928
- }
929
- catch {
930
- // malformed
931
- }
932
- }
933
- return { lines, parsed, malformed: lines - parsed };
934
- }
935
- // Find leftover proper-lockfile lock dirs (`<file>.lock`) across the state
936
- // dirs. Anything older than the threshold is almost certainly orphaned by a
937
- // crashed writer (withLock's stale window is 5s).
938
- async function scanStaleLocks(olderThanMs, now) {
939
- const out = [];
940
- const dirs = [ROOT, INBOX_DIR, CURSOR_DIR, ROOMS_DIR, TRANSPORT_DIR];
941
- for (const dir of dirs) {
942
- if (!existsSync(dir))
943
- continue;
944
- let names;
945
- try {
946
- names = await fsp.readdir(dir);
947
- }
948
- catch {
949
- continue;
950
- }
951
- for (const name of names) {
952
- if (!name.endsWith(".lock"))
953
- continue;
954
- const p = path.join(dir, name);
955
- try {
956
- const st = await fsp.stat(p);
957
- const ageMs = now - st.mtimeMs;
958
- if (ageMs > olderThanMs)
959
- out.push({ path: p, ageMs });
960
- }
961
- catch {
962
- // vanished mid-scan
963
- }
964
- }
965
- }
966
- return out;
967
- }
968
- export const doctorSchema = {
969
- fix: z.boolean().optional(),
970
- maxFileBytes: z.number().int().positive().optional(),
971
- };
972
- export async function doctorTool(args) {
973
- const fix = args.fix ?? false;
974
- const maxBytes = args.maxFileBytes ?? 5 * 1024 * 1024;
975
- const now = Date.now();
976
- const findings = [];
977
- const fixed = [];
978
- const reg = await readJson(AGENTS_FILE, {});
979
- const known = new Set(Object.keys(reg));
980
- const rooms = await getRooms();
981
- const channels = Object.keys(rooms);
982
- // 1. Orphan transport markers (dead local pid, or stale remote heartbeat).
983
- {
984
- const dead = [];
985
- for (const fname of await listTransportFiles()) {
986
- const file = path.join(TRANSPORT_DIR, fname);
987
- const marker = await readJson(file, null);
988
- if (!marker || !isMarkerLive(marker, reg, now)) {
989
- dead.push(file);
990
- if (fix) {
991
- await deleteFile(file);
992
- fixed.push(`deleted stale transport marker ${fname}`);
993
- }
994
- }
995
- }
996
- findings.push({
997
- check: "orphan-transport-markers",
998
- level: dead.length ? "warn" : "ok",
999
- detail: dead.length ? `${dead.length} stale transport marker(s) (dead pid or expired remote heartbeat)` : "no stale transport markers",
1000
- fixable: true,
1001
- items: dead.length ? dead.map((f) => path.basename(f)) : undefined,
1002
- });
1003
- }
1004
- // 1b. Stale pusher daemons — a long-running pusher loaded its script into
1005
- // memory at spawn time, so when the on-disk script is later upgraded
1006
- // (npm i -g a new version), the still-running pid is on the OLD code.
1007
- // Pre-v0.8.2 pushers had no `control:true` awareness and silently
1008
- // dropped /clear /compact at the slash-guard — ack:true with no
1009
- // keystrokes ever reaching the pane. Comparing the marker's stamped
1010
- // scriptMtime against the newest on-disk mtime across hooks/*.mjs
1011
- // catches it — the whole module graph, not just the entry file, because
1012
- // the control-submit logic lives in submit.mjs and a fix landing there
1013
- // alone leaves tmux-pusher.mjs's mtime (and a single-file stamp) intact.
1014
- // Local tmux-push only — for tmux-push-remote the script lives on a
1015
- // different host so we can't stat it from here.
1016
- {
1017
- const localPusherMtime = newestPusherSourceMtime();
1018
- const stale = [];
1019
- const unverifiable = [];
1020
- for (const fname of await listTransportFiles()) {
1021
- const file = path.join(TRANSPORT_DIR, fname);
1022
- const marker = await readJson(file, null);
1023
- if (!marker || !isMarkerLive(marker, reg, now))
1024
- continue;
1025
- if (marker.transport !== "tmux-push")
1026
- continue; // remote = can't verify (documented limit: can't stat another host)
1027
- if (marker.scriptMtime === undefined) {
1028
- // ABSENCE IS NOT EXEMPTION. The field's own writer once dropped it,
1029
- // and the silent skip here meant the check was disabled by the very
1030
- // thing it monitors — a live pusher we cannot verify is a warn, not
1031
- // an ok (credit agent-coordination-david-dev). Same flip, same
1032
- // commit, as serverBuildMtime below: the two checks must never
1033
- // disagree about what absence means.
1034
- unverifiable.push(`${marker.agentId} (pid ${marker.pid}, no scriptMtime stamp — cannot verify; detach_agent + attach_agent to re-stamp)`);
1035
- continue;
1036
- }
1037
- if (localPusherMtime === undefined)
1038
- continue;
1039
- if (marker.scriptMtime < localPusherMtime - 1) { // -1ms slack for fs mtime rounding
1040
- const loaded = new Date(marker.scriptMtime).toISOString();
1041
- const ondisk = new Date(localPusherMtime).toISOString();
1042
- stale.push(`${marker.agentId} (pid ${marker.pid}, loaded ${loaded}, on-disk now ${ondisk})`);
1043
- }
1044
- }
1045
- const bad = [...stale, ...unverifiable];
1046
- findings.push({
1047
- check: "stale-pusher-script",
1048
- level: bad.length ? "warn" : "ok",
1049
- detail: bad.length
1050
- ? `${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.`
1051
- : "all attached pushers are running the current on-disk script",
1052
- fixable: false,
1053
- items: bad.length ? bad : undefined,
1054
- });
1055
- }
1056
- // 1b². The same staleness class one layer up: doctor itself runs inside an
1057
- // MCP server process that imported dist/ at startup. `npm run build`
1058
- // rewrites dist/ under the still-running server, which then keeps
1059
- // spawning pushers and stamping markers with logic the rebuild
1060
- // replaced — merging is not deploying, and until the session restarts
1061
- // no on-disk artifact reflects what this process will actually do.
1062
- // Self-scoped by construction: each session's doctor reports on the
1063
- // server it is running in, which is the only process whose loaded
1064
- // build it can truthfully know.
1065
- {
1066
- const onDisk = onDiskBuildMtime();
1067
- const drifted = SERVER_BUILD_MTIME !== undefined && onDisk !== undefined && SERVER_BUILD_MTIME < onDisk - 1;
1068
- const identity = `${SERVER_BUILD_SHA ?? "unknown-sha"} @ ${BUILD_DIR}`;
1069
- findings.push({
1070
- check: "server-build-drift",
1071
- level: drifted ? "warn" : "ok",
1072
- detail: drifted
1073
- ? `this MCP server loaded its build at ${new Date(SERVER_BUILD_MTIME).toISOString()} but the on-disk build is newer (${new Date(onDisk).toISOString()}) — the session is running pre-rebuild code and everything it stamps or spawns uses replaced logic. Restart this agent's session. (${identity})`
1074
- : `server is running the current on-disk build (${identity})`,
1075
- fixable: false,
1076
- });
1077
- }
1078
- // 1b³. WHERE A NEW GLOBAL INSTALL WOULD LAND, which is a different question
1079
- // from which copy is running (1b² above) and neither substitutes. On
1080
- // this box `npm prefix -g` and the fleet's actual load path pointed at
1081
- // two different nvm versions, so an install that printed success would
1082
- // have updated a copy nothing loads.
1083
- {
1084
- const loadPrefix = prefixOf(BUILD_DIR);
1085
- let installPrefix = null;
1086
- let npmPath;
1087
- try {
1088
- installPrefix = execFileSync("npm", ["prefix", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
1089
- npmPath = execFileSync("which", ["npm"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
1090
- }
1091
- catch {
1092
- installPrefix = null;
1093
- }
1094
- const v = prefixVerdict(loadPrefix, installPrefix, npmPath);
1095
- findings.push({ check: "install-prefix", level: v.level, detail: v.detail, fixable: false });
1096
- }
1097
- // 1b²ᵇ. The affirmative catch for merged-but-never-rebuilt: src/ newer than
1098
- // the compiled build means no restart can help — the artifact every
1099
- // future session will load is already behind the code. Distinct from
1100
- // 1b² (a process behind its dist); this is the DISK being behind
1101
- // itself, which is why it can fire on a bus with zero live sessions.
1102
- // Not inferred from marker state: both sides are statted directly.
1103
- {
1104
- const srcMtime = onDiskSourceMtime();
1105
- const distMtime = onDiskBuildMtime();
1106
- const behind = srcMtime !== undefined && distMtime !== undefined && distMtime < srcMtime - 1;
1107
- findings.push({
1108
- check: "dist-behind-source",
1109
- level: behind ? "warn" : "ok",
1110
- detail: behind
1111
- ? `src/ is newer than the compiled build (src ${new Date(srcMtime).toISOString()}, dist ${new Date(distMtime).toISOString()}) — the checkout was updated but never rebuilt, so every session (current and future) runs pre-update code. \`npm run build\`, then restart sessions.`
1112
- : srcMtime === undefined
1113
- ? "no src/ to compare (packaged install) — dist is the only artifact"
1114
- : "compiled build is at least as new as src/",
1115
- fixable: false,
1116
- });
1117
- }
1118
- // 1b³. Marker provenance — which server BUILD stamped each marker. A live
1119
- // pusher can be perfectly fresh while the marker's stamps were
1120
- // computed by an outdated server (observed live 2026-07-29: a stale
1121
- // server's attach stamped single-file freshness that agreed with the
1122
- // new on-disk check only because tmux-pusher.mjs happened to be the
1123
- // newest hooks file). Local tmux-push only, same as 1b.
1124
- {
1125
- const onDisk = onDiskBuildMtime();
1126
- const unverifiable = [];
1127
- for (const fname of await listTransportFiles()) {
1128
- const file = path.join(TRANSPORT_DIR, fname);
1129
- const marker = await readJson(file, null);
1130
- if (!marker || !isMarkerLive(marker, reg, now))
1131
- continue;
1132
- if (marker.transport !== "tmux-push")
1133
- continue; // remote = can't verify (documented limit: can't stat another host)
1134
- if (marker.serverBuildMtime === undefined) {
1135
- // ABSENCE IS NOT EXEMPTION — flipped in the same commit as the
1136
- // scriptMtime absence above, so the two checks can never disagree
1137
- // about what a missing stamp means. An unstamped marker was written
1138
- // by a pre-provenance server (or by hand): precisely the population
1139
- // this check exists to police, and the one it must not exempt.
1140
- // Transition is deliberately correct-and-loud: after the upgrade,
1141
- // every pre-existing marker warns at once, each cleared by a session
1142
- // restart + re-attach — the burst is also the only external view of
1143
- // which sessions still run pre-provenance servers, since a stale
1144
- // server cannot self-report (see 1b²).
1145
- unverifiable.push(`${marker.agentId} (no serverBuildMtime stamp — stamped by a pre-provenance server; restart that session, then detach_agent + attach_agent)`);
1146
- continue;
1147
- }
1148
- if (onDisk === undefined)
1149
- continue;
1150
- if (marker.serverBuildMtime < onDisk - 1) {
1151
- // A COMPONENT CAN REPORT ITS OWN FRESHNESS; A SIBLING'S RECORD OF IT
1152
- // GOES STALE SILENTLY. That asymmetry is the whole correction here.
1153
- //
1154
- // `serverBuildMtime` is stamped inside `attach_agent` — by the PUSHER's
1155
- // attach, once — so it records what the server was AT THE LAST ATTACH.
1156
- // A server-only restart (David's `/mcp`) reloads the server and touches
1157
- // no marker, so this stamp cannot be refreshed by the thing it
1158
- // describes and CANNOT DISTINGUISH a restarted server from an
1159
- // un-restarted one.
1160
- //
1161
- // Measured on three agents: each answered `list_subscriptions` — a tool
1162
- // that does not exist before 0.26.7 — while its marker still stamped a
1163
- // pre-0.26.7 build. The stamp was 2.2 hours behind the installed dist
1164
- // on a server demonstrably running the new code.
1165
- //
1166
- // So this is reported as what it IS: the marker predates the build, and
1167
- // the server's actual state is UNKNOWN from here. Calling it an
1168
- // outdated server is a claim this record cannot support, and it priced
1169
- // a restart decision on a count where at least three entries had
1170
- // already restarted.
1171
- //
1172
- // The pusher half is untouched and remains valid: `scriptMtime` is
1173
- // self-reported by the process it describes.
1174
- const stamped = new Date(marker.serverBuildMtime).toISOString();
1175
- const current = new Date(onDisk).toISOString();
1176
- unverifiable.push(`${marker.agentId} (marker stamped at the last ATTACH from server build ${stamped}, on-disk build ${current} — this says the MARKER is behind, NOT that the server is: a server-only restart refreshes no marker. Exercise the server to settle it, or detach_agent + attach_agent to refresh the stamp)`);
1177
- }
1178
- }
1179
- // `outdated` is gone, not emptied: nothing can populate it any more, and a
1180
- // bucket that always reports 0 is a claim the reader still has to discount.
1181
- const bad = unverifiable;
1182
- findings.push({
1183
- check: "marker-server-provenance",
1184
- level: bad.length ? "warn" : "ok",
1185
- detail: bad.length
1186
- ? `${bad.length} transport marker(s) cannot report their server's build: the stamp is written by attach_agent, so it records the server AT THE LAST ATTACH and a server-only restart refreshes nothing. This says the MARKER is behind or absent — NOT that the server is stale. Exercise the server to settle it (a tool that exists only in the newer build), or detach_agent + attach_agent to refresh the stamp. Restart the session too if the server itself is old.`
1187
- : "every local transport marker was stamped at an attach against the current build — which says the markers are current, not that every server is",
1188
- fixable: false,
1189
- items: bad.length ? bad : undefined,
1190
- });
1191
- }
1192
- // 1b³. THE SPLIT: one pane, two components, ONE version number.
1193
- //
1194
- // The two stamps above are each checked, and each in its own finding — which
1195
- // means the state that actually bit us has no name. `detach_agent` +
1196
- // `attach_agent` respawns the PUSHER and reloads its script; **it does not
1197
- // restart the MCP SERVER.** So a pane can run a current pusher against a
1198
- // two-day-old server, and both existing checks are individually correct while
1199
- // nothing says "this agent is split". Measured across this machine: all three
1200
- // panes ran a 2026-08-27 pusher hook against a 2026-08-25 server build.
1201
- //
1202
- // WHY IT IS A DISPLAY TASK AND NOT A DESIGN ONE: both facts were already
1203
- // written into every marker. Nothing needed adding; the pair needed reading
1204
- // together, and reading them apart is what let "we restarted the transports"
1205
- // be heard as "we restarted the fleet".
1206
- //
1207
- // The classification is the deliverable, because the REMEDIES differ: SPLIT
1208
- // needs a session restart (re-attach will not touch the server), fully-stale
1209
- // needs both, fresh needs nothing. Reporting two independent warnings leaves
1210
- // the reader to infer which — and the inference that was actually made was the
1211
- // optimistic one.
1212
- //
1213
- // Every timestamp is ISO-8601 UTC (`Z`), so no reading depends on the reader's
1214
- // timezone.
1215
- {
1216
- const onDiskScript = newestPusherSourceMtime();
1217
- const onDiskServer = onDiskBuildMtime();
1218
- const split = [];
1219
- const bothStale = [];
1220
- const unknown = [];
1221
- let fresh = 0;
1222
- for (const fname of await listTransportFiles()) {
1223
- const file = path.join(TRANSPORT_DIR, fname);
1224
- const marker = await readJson(file, null);
1225
- if (!marker || !isMarkerLive(marker, reg, now))
1226
- continue;
1227
- if (marker.transport !== "tmux-push")
1228
- continue; // remote: the script lives on another host
1229
- const { scriptMtime, serverBuildMtime, agentId } = marker;
1230
- if (scriptMtime === undefined || serverBuildMtime === undefined || onDiskScript === undefined || onDiskServer === undefined) {
1231
- // A pane missing either stamp cannot be classified. Saying so is the
1232
- // point: "unclassifiable" and "fresh" are different answers, and the
1233
- // whole finding is about not collapsing them.
1234
- unknown.push(`${agentId} (pusher ${stamp(scriptMtime)} · server ${stamp(serverBuildMtime)} — one stamp missing, cannot classify)`);
1235
- continue;
1236
- }
1237
- const scriptStale = scriptMtime < onDiskScript - 1;
1238
- const serverStale = serverBuildMtime < onDiskServer - 1;
1239
- const pair = `${agentId} (pusher ${stamp(scriptMtime)} · server ${stamp(serverBuildMtime)})`;
1240
- if (scriptStale && serverStale)
1241
- bothStale.push(pair);
1242
- else if (scriptStale !== serverStale) {
1243
- split.push(`${pair} — ${scriptStale ? "pusher stale, server current" : "pusher CURRENT, server STALE"}`);
1244
- }
1245
- else
1246
- fresh++;
1247
- }
1248
- const bad = [...split, ...bothStale, ...unknown];
1249
- findings.push({
1250
- check: "transport-build-split",
1251
- level: split.length || bothStale.length ? "warn" : unknown.length ? "warn" : "ok",
1252
- detail: bad.length
1253
- ? `${split.length} pane(s) SPLIT (the two components are at different builds under one version number — a re-attach reloads the pusher and NOT the server, so this needs a SESSION restart), ` +
1254
- `${bothStale.length} fully stale, ${unknown.length} unclassifiable, ${fresh} fully current. On-disk now: pusher ${stamp(onDiskScript)} · server ${stamp(onDiskServer)}. Times are ISO-8601 UTC.`
1255
- : `${fresh} local pane(s) fully current — pusher and server both at the on-disk build (ISO-8601 UTC)`,
1256
- fixable: false,
1257
- items: bad.length ? bad : undefined,
1258
- });
1259
- }
1260
- // 1c. Wedged local pushers (pid-alive, pane-dead). v0.8.0 made pushers
1261
- // self-exit when their own tmux-target probe finds the pane gone, but
1262
- // that only fires from inside the pusher's own poll loop — if the pane
1263
- // is killed in a way that loop never observes (or the loop itself is
1264
- // wedged), the pid stays alive, isMarkerLive's pid-alive check keeps
1265
- // treating it as live, and list_agents reports it "live" while nothing
1266
- // can actually be delivered. Local tmux-push only — a tmux-push-remote
1267
- // marker's pane lives on a different host, unprobeable from here.
1268
- {
1269
- // Without a tmux binary we can't tell "wedged" from "can't probe" — skip
1270
- // rather than flag every local marker as dead.
1271
- const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
1272
- const wedged = [];
1273
- if (tmuxAvailable) {
1274
- for (const fname of await listTransportFiles()) {
1275
- const file = path.join(TRANSPORT_DIR, fname);
1276
- const marker = await readJson(file, null);
1277
- if (!marker || !isMarkerLive(marker, reg, now))
1278
- continue;
1279
- if (marker.transport !== "tmux-push")
1280
- continue; // remote = no local pane to probe
1281
- if (!marker.tmuxTarget)
1282
- continue; // no target recorded, can't probe
1283
- // has-session actually validates the target and fails on a dead
1284
- // pane/session; `display-message -p -t <target> <literal>` does NOT
1285
- // (tmux 3.6b exits 0 for any target, even a just-killed one, when
1286
- // the format string has no #{...} needing that target resolved).
1287
- const probe = spawnSync("tmux", ["has-session", "-t", marker.tmuxTarget]);
1288
- if (probe.status === 0)
1289
- continue; // pane alive
1290
- // The marker's pid being alive does not make it OUR pid — see
1291
- // isPusherProcess. Record the verdict now so `fix` only ever signals
1292
- // a confirmed pusher.
1293
- wedged.push({
1294
- agentId: marker.agentId,
1295
- pid: marker.pid,
1296
- file,
1297
- target: marker.tmuxTarget,
1298
- isPusher: isPusherProcess(marker.pid),
1299
- });
1300
- }
1301
- }
1302
- if (fix) {
1303
- for (const w of wedged) {
1304
- // Clearing the marker is always safe — the pane is gone either way, so
1305
- // nothing can be delivered through it. Signalling is not: an
1306
- // unverifiable pid is some other process that inherited this number.
1307
- if (w.isPusher) {
1308
- try {
1309
- process.kill(w.pid, "SIGTERM");
1310
- }
1311
- catch { /* already gone */ }
1312
- }
1313
- await deleteFile(w.file);
1314
- fixed.push(w.isPusher
1315
- ? `reaped wedged pusher for ${w.agentId} (pid ${w.pid}, tmux target '${w.target}' gone)`
1316
- : `cleared stale transport marker for ${w.agentId} (tmux target '${w.target}' gone; pid ${w.pid} is not a tmux-pusher — not signalled)`);
1317
- }
1318
- }
1319
- findings.push({
1320
- check: "wedged-local-pushers",
1321
- level: wedged.length ? "warn" : "ok",
1322
- detail: wedged.length
1323
- ? `${wedged.length} local pusher(s) alive (pid) but their tmux pane is gone — looks attached, delivers nothing. ${fix ? "Reaped (SIGTERM + marker cleared)." : "Run doctor with fix:true to SIGTERM and clear the marker."}`
1324
- : tmuxAvailable
1325
- ? "no wedged local pushers (pid-alive, pane-dead)"
1326
- : "tmux not available — skipped wedged-pusher pane probe",
1327
- fixable: true,
1328
- items: wedged.length
1329
- ? wedged.map((w) => `${w.agentId} (pid ${w.pid}, tmux target '${w.target}')${w.isPusher ? "" : " — pid is not a tmux-pusher, marker will be cleared without signalling"}`)
1330
- : undefined,
1331
- });
1332
- }
1333
- // 1d. Duplicate session bindings — two live MCP sessions bound to one agent
1334
- // id means two processes are ACTING as the same agent (the
1335
- // `<project>-liaison` shape: a dev session bound onto a live worker's id;
1336
- // force/token make that possible on purpose, this makes it visible).
1337
- // Bindings are per-process closure state, so this reads the on-disk
1338
- // session markers stdio servers write at bind time. A marker whose pid
1339
- // is dead is litter from a killed session (default signal death skips
1340
- // exit handlers) — cleaned under fix. Live duplicates are NOT auto-
1341
- // fixable: doctor cannot know which of two running sessions is the
1342
- // impostor; the wrong session should `quit` (its marker clears on exit).
1343
- {
1344
- const byAgent = new Map();
1345
- const stale = [];
1346
- for (const file of await listSessionFiles()) {
1347
- const s = await readJson(file, null);
1348
- if (!s || typeof s.pid !== "number" || !s.agentId || !isPidAlive(s.pid)) {
1349
- stale.push(path.basename(file));
1350
- if (fix) {
1351
- await deleteFile(file);
1352
- fixed.push(`deleted stale session binding ${path.basename(file)}`);
1353
- }
1354
- continue;
1355
- }
1356
- const list = byAgent.get(s.agentId) ?? [];
1357
- list.push({ pid: s.pid, via: s.via ?? "unknown", boundAt: s.boundAt ?? 0 });
1358
- byAgent.set(s.agentId, list);
1359
- }
1360
- const dupes = [];
1361
- for (const [id, list] of byAgent) {
1362
- if (list.length < 2)
1363
- continue;
1364
- dupes.push(`${id} — ${list
1365
- .map((b) => `pid ${b.pid} (via ${b.via}, bound ${b.boundAt ? new Date(b.boundAt).toISOString() : "unknown"})`)
1366
- .join(" AND ")}`);
1367
- }
1368
- findings.push({
1369
- check: "duplicate-session-binding",
1370
- level: dupes.length ? "warn" : "ok",
1371
- detail: dupes.length
1372
- ? `${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).`
1373
- : stale.length
1374
- ? `no duplicate session bindings (${stale.length} stale binding file(s) from dead sessions${fix ? " — cleaned" : "; run doctor with fix:true to clean"})`
1375
- : "no duplicate session bindings",
1376
- fixable: true,
1377
- items: dupes.length ? dupes : undefined,
1378
- });
1379
- }
1380
- // 2. Orphan room memberships (member not in the registry).
1381
- {
1382
- const orphans = new Set();
1383
- for (const e of Object.values(rooms)) {
1384
- for (const m of e.members ?? [])
1385
- if (!known.has(m))
1386
- orphans.add(m);
1387
- }
1388
- if (fix && orphans.size) {
1389
- await updateJson(ROOMS_FILE, {}, (cur) => {
1390
- for (const e of Object.values(cur)) {
1391
- if (e.members?.length)
1392
- e.members = e.members.filter((m) => known.has(m));
1393
- }
1394
- return cur;
1395
- });
1396
- fixed.push(`dropped ${orphans.size} orphan membership(s): ${[...orphans].join(", ")}`);
1397
- }
1398
- findings.push({
1399
- check: "orphan-room-memberships",
1400
- level: orphans.size ? "warn" : "ok",
1401
- detail: orphans.size ? `${orphans.size} channel member(s) not in the registry` : "all channel members are registered",
1402
- fixable: true,
1403
- items: orphans.size ? [...orphans] : undefined,
1404
- });
1405
- }
1406
- // 3. Orphan inbox / cursor files (owner not registered).
1407
- {
1408
- const orphanInbox = [];
1409
- for (const fname of await listInboxFiles()) {
1410
- const id = fname.replace(/\.jsonl$/, "");
1411
- if (!known.has(id)) {
1412
- orphanInbox.push(id);
1413
- if (fix) {
1414
- await deleteFile(path.join(INBOX_DIR, fname));
1415
- fixed.push(`deleted orphan inbox ${fname}`);
1416
- }
1417
- }
1418
- }
1419
- const orphanCursor = [];
1420
- for (const fname of await listCursorFiles()) {
1421
- const id = fname.replace(/\.json$/, "");
1422
- if (!known.has(id)) {
1423
- orphanCursor.push(id);
1424
- if (fix) {
1425
- await deleteFile(path.join(CURSOR_DIR, fname));
1426
- fixed.push(`deleted orphan cursor ${fname}`);
1427
- }
1428
- }
1429
- }
1430
- const total = orphanInbox.length + orphanCursor.length;
1431
- findings.push({
1432
- check: "orphan-inboxes-cursors",
1433
- level: total ? "warn" : "ok",
1434
- detail: total
1435
- ? `${orphanInbox.length} inbox + ${orphanCursor.length} cursor file(s) for unregistered ids`
1436
- : "no orphan inbox/cursor files",
1437
- fixable: true,
1438
- items: total ? [...new Set([...orphanInbox, ...orphanCursor])] : undefined,
1439
- });
1440
- }
1441
- // Precompute parsed line counts for cursor + malformed checks.
1442
- const counts = new Map();
1443
- const countFor = async (file) => {
1444
- if (!counts.has(file))
1445
- counts.set(file, await scanJsonl(file));
1446
- return counts.get(file);
1447
- };
1448
- // 4. Cursor offsets past end-of-file (would return [] forever).
1449
- {
1450
- const broken = [];
1451
- for (const fname of await listCursorFiles()) {
1452
- const id = fname.replace(/\.json$/, "");
1453
- const cursorPath = path.join(CURSOR_DIR, fname);
1454
- const cursor = await readJson(cursorPath, {});
1455
- const overflow = [];
1456
- const inboxMax = (await countFor(inboxFile(id))).parsed;
1457
- if ((cursor.inboxOffset ?? 0) > inboxMax)
1458
- overflow.push(`inboxOffset ${cursor.inboxOffset}>${inboxMax}`);
1459
- const roomMax = (await countFor(ROOM_FILE)).parsed;
1460
- if ((cursor.roomOffset ?? 0) > roomMax)
1461
- overflow.push(`roomOffset ${cursor.roomOffset}>${roomMax}`);
1462
- const statusMax = (await countFor(STATUS_FILE)).parsed;
1463
- if ((cursor.statusOffset ?? 0) > statusMax)
1464
- overflow.push(`statusOffset ${cursor.statusOffset}>${statusMax}`);
1465
- for (const [chan, off] of Object.entries(cursor.roomOffsets ?? {})) {
1466
- const max = (await countFor(roomFile(chan))).parsed;
1467
- if (off > max)
1468
- overflow.push(`roomOffsets[${chan}] ${off}>${max}`);
1469
- }
1470
- if (overflow.length) {
1471
- broken.push(`${id}: ${overflow.join(", ")}`);
1472
- if (fix) {
1473
- await updateJson(cursorPath, {}, (c) => {
1474
- if ((c.inboxOffset ?? 0) > inboxMax)
1475
- c.inboxOffset = inboxMax;
1476
- if ((c.roomOffset ?? 0) > roomMax)
1477
- c.roomOffset = roomMax;
1478
- if ((c.statusOffset ?? 0) > statusMax)
1479
- c.statusOffset = statusMax;
1480
- if (c.roomOffsets) {
1481
- for (const chan of Object.keys(c.roomOffsets)) {
1482
- const max = counts.get(roomFile(chan))?.parsed ?? 0;
1483
- if (c.roomOffsets[chan] > max)
1484
- c.roomOffsets[chan] = max;
1485
- }
1486
- }
1487
- return c;
1488
- });
1489
- fixed.push(`clamped cursor offsets for ${id}`);
1490
- }
1491
- }
1492
- }
1493
- findings.push({
1494
- check: "cursor-past-eof",
1495
- level: broken.length ? "error" : "ok",
1496
- detail: broken.length ? `${broken.length} cursor(s) with an offset past EOF — delivery stalled` : "all cursor offsets are within bounds",
1497
- fixable: true,
1498
- items: broken.length ? broken : undefined,
1499
- });
1500
- }
1501
- // 5. Malformed JSONL lines (silently desync offset math between server + hooks).
1502
- {
1503
- const jsonlFiles = [
1504
- ROOM_FILE,
1505
- STATUS_FILE,
1506
- ...channels.filter((c) => c !== DEFAULT_ROOM).map((c) => roomFile(c)),
1507
- ...(await listInboxFiles()).map((f) => path.join(INBOX_DIR, f)),
1508
- ];
1509
- const bad = [];
1510
- for (const file of jsonlFiles) {
1511
- const c = await countFor(file);
1512
- if (c.malformed > 0) {
1513
- bad.push(`${path.basename(file)} (${c.malformed})`);
1514
- if (fix) {
1515
- await fsp.copyFile(file, file + ".bak");
1516
- await rewriteJsonl(file, () => true); // drops unparseable lines
1517
- fixed.push(`rewrote ${path.basename(file)} dropping ${c.malformed} malformed line(s) (backup: ${path.basename(file)}.bak)`);
1518
- }
1519
- }
1520
- }
1521
- findings.push({
1522
- check: "malformed-jsonl",
1523
- level: bad.length ? "warn" : "ok",
1524
- detail: bad.length ? `${bad.length} file(s) contain unparseable lines` : "no malformed JSONL lines",
1525
- fixable: true,
1526
- items: bad.length ? bad : undefined,
1527
- });
1528
- }
1529
- // 6. Stale agents (registered, no live transport, heartbeat past EVICT_MS). Report only.
1530
- {
1531
- // Compute liveness WITHOUT deleting dead markers — loadLiveTransports
1532
- // prunes as a side effect, which would make this read-only check mutate
1533
- // state (and pre-empt the orphan-marker fix in check 1).
1534
- const live = new Set();
1535
- for (const fname of await listTransportFiles()) {
1536
- const marker = await readJson(path.join(TRANSPORT_DIR, fname), null);
1537
- if (marker && isMarkerLive(marker, reg, now))
1538
- live.add(marker.agentId);
1539
- }
1540
- const stale = [];
1541
- for (const [id, a] of Object.entries(reg)) {
1542
- if (live.has(id))
1543
- continue;
1544
- if (now - a.lastHeartbeat > EVICT_MS)
1545
- stale.push(`${id} (${Math.floor((now - a.lastHeartbeat) / 3600000)}h)`);
1546
- }
1547
- findings.push({
1548
- check: "stale-agents",
1549
- level: stale.length ? "warn" : "ok",
1550
- detail: stale.length ? `${stale.length} agent(s) past the eviction window — next list_agents will drop them` : "no stale agents",
1551
- fixable: false,
1552
- items: stale.length ? stale : undefined,
1553
- });
1554
- }
1555
- // 6b. STALENESS HAS TWO CAUSES AND THEY LOOK IDENTICAL IN A FLAT LIST.
1556
- //
1557
- // Check 6 reports "these N agents are stale", which is the detection half and
1558
- // leaves the reader to infer the cause. The inference is the expensive part: a
1559
- // coordinator misdiagnosed a billing outage as a coordination failure for want
1560
- // of exactly this distinction, and spent the incident chasing agents.
1561
- //
1562
- // UNIFORM every agent stale by roughly the same interval. Agents do not
1563
- // fail in lockstep — something they SHARE did: the machine slept,
1564
- // the API key expired, billing lapsed, the host lost network. The
1565
- // remedy is the environment, and touching the agents does nothing.
1566
- // DIVERGENT some stale, some fresh, on the same bus at the same moment. The
1567
- // shared substrate is demonstrably working, so the fault is the
1568
- // stale agent's. The remedy is that agent.
1569
- //
1570
- // CLASSIFICATION IS THE DELIVERABLE, NOT DETECTION — the same finding as
1571
- // `transport-build-split`: two states with different remedies reported as one
1572
- // warning leave the reader to guess, and the guess is made under incident
1573
- // pressure.
1574
- //
1575
- // WITH FEWER THAN TWO AGENTS THE QUESTION IS UNANSWERABLE and it says so. One
1576
- // stale agent on a bus of one is uniform and divergent simultaneously; there is
1577
- // no second observation to compare against. Naming a cause there would be the
1578
- // confident-wrong-answer shape this whole class is about.
1579
- {
1580
- const live = new Set();
1581
- for (const fname of await listTransportFiles()) {
1582
- const marker = await readJson(path.join(TRANSPORT_DIR, fname), null);
1583
- if (marker && isMarkerLive(marker, reg, now))
1584
- live.add(marker.agentId);
1585
- }
1586
- const entries = Object.entries(reg).map(([id, a]) => ({
1587
- id,
1588
- live: live.has(id),
1589
- ageMs: now - a.lastHeartbeat,
1590
- stale: !live.has(id) && now - a.lastHeartbeat > STALE_MS,
1591
- }));
1592
- const stale = entries.filter((e) => e.stale);
1593
- const fresh = entries.filter((e) => !e.stale);
1594
- const mins = (ms) => Math.round(ms / 60000);
1595
- let level = "ok";
1596
- let detail;
1597
- let items;
1598
- if (entries.length === 0) {
1599
- detail = "no registered agents — nothing to classify";
1600
- }
1601
- else if (stale.length === 0) {
1602
- detail = `${entries.length} agent(s), none stale (threshold ${mins(STALE_MS)}m)`;
1603
- }
1604
- else if (entries.length < 2) {
1605
- level = "warn";
1606
- detail =
1607
- `1 agent and it is stale (${mins(stale[0].ageMs)}m) — UNCLASSIFIABLE. Uniform and divergent are the same ` +
1608
- `picture with one observation, so the cause is not named here rather than guessed.`;
1609
- items = [`${stale[0].id} (${mins(stale[0].ageMs)}m)`];
1610
- }
1611
- else if (fresh.length === 0) {
1612
- // Spread across the stale set decides it: a shared cause stops everything at
1613
- // once, so the ages cluster. Independent failures do not.
1614
- const ages = stale.map((e) => e.ageMs).sort((a, b) => a - b);
1615
- const spread = ages[ages.length - 1] - ages[0];
1616
- const tight = spread <= Math.max(2 * 60 * 1000, ages[ages.length - 1] * 0.1);
1617
- level = "warn";
1618
- detail = tight
1619
- ? `UNIFORM: all ${stale.length} agent(s) stale within ${mins(spread)}m of each other (${mins(ages[0])}–${mins(ages[ages.length - 1])}m). ` +
1620
- `Agents do not fail in lockstep — suspect something they SHARE (machine asleep, credentials, billing, network). ` +
1621
- `Restarting agents will not help.`
1622
- : `ALL ${stale.length} agent(s) are stale but their ages span ${mins(spread)}m — NOT uniform, so a single shared cause does not explain it. ` +
1623
- `Treat as ${stale.length} independent failures until something ties them together.`;
1624
- items = stale.map((e) => `${e.id} (${mins(e.ageMs)}m)`);
1625
- }
1626
- else {
1627
- level = "warn";
1628
- detail =
1629
- `DIVERGENT: ${stale.length} stale, ${fresh.length} fresh on the same bus at the same moment. ` +
1630
- `The shared substrate is demonstrably working — the fault is the stale agent's, not the environment's.`;
1631
- items = stale.map((e) => `${e.id} (${mins(e.ageMs)}m stale)`);
1632
- }
1633
- findings.push({ check: "staleness-class", level, detail, fixable: false, ...(items ? { items } : {}) });
1634
- }
1635
- // 7. Oversized JSONL files. Report only (suggest prune).
1636
- {
1637
- const big = [];
1638
- const candidates = [
1639
- ROOM_FILE,
1640
- STATUS_FILE,
1641
- ...channels.filter((c) => c !== DEFAULT_ROOM).map((c) => roomFile(c)),
1642
- ...(await listInboxFiles()).map((f) => path.join(INBOX_DIR, f)),
1643
- ];
1644
- for (const file of candidates) {
1645
- const sz = await fileSize(file);
1646
- if (sz > maxBytes)
1647
- big.push(`${path.basename(file)} (${(sz / 1024 / 1024).toFixed(1)}MB)`);
1648
- }
1649
- findings.push({
1650
- check: "oversized-files",
1651
- level: big.length ? "warn" : "ok",
1652
- detail: big.length ? `${big.length} file(s) over ${(maxBytes / 1024 / 1024).toFixed(0)}MB — consider prune` : "no oversized files",
1653
- fixable: false,
1654
- items: big.length ? big : undefined,
1655
- });
1656
- }
1657
- // 8. Stale lock dirs from crashed writers.
1658
- {
1659
- const locks = await scanStaleLocks(60_000, now);
1660
- for (const l of locks) {
1661
- if (fix) {
1662
- try {
1663
- await fsp.rm(l.path, { recursive: true, force: true });
1664
- fixed.push(`removed stale lock ${path.basename(l.path)}`);
1665
- }
1666
- catch {
1667
- // ignore
1668
- }
1669
- }
1670
- }
1671
- findings.push({
1672
- check: "stale-locks",
1673
- level: locks.length ? "warn" : "ok",
1674
- detail: locks.length ? `${locks.length} lock dir(s) older than 60s — likely from a crashed writer` : "no stale locks",
1675
- fixable: true,
1676
- items: locks.length ? locks.map((l) => `${path.basename(l.path)} (${Math.floor(l.ageMs / 1000)}s)`) : undefined,
1677
- });
1678
- }
1679
- // 9. Channel/registry consistency: rooms/<chan>.jsonl files without a registry entry.
1680
- {
1681
- const orphanFiles = [];
1682
- if (existsSync(ROOMS_DIR)) {
1683
- let names = [];
1684
- try {
1685
- names = await fsp.readdir(ROOMS_DIR);
1686
- }
1687
- catch {
1688
- // ignore
1689
- }
1690
- for (const name of names) {
1691
- if (!name.endsWith(".jsonl"))
1692
- continue;
1693
- const chan = name.replace(/\.jsonl$/, "");
1694
- if (!rooms[chan]) {
1695
- orphanFiles.push(name);
1696
- if (fix) {
1697
- await ensureRoom(chan, "doctor");
1698
- fixed.push(`registered channel '${chan}' (had a JSONL file but no registry entry)`);
1699
- }
1700
- }
1701
- }
1702
- }
1703
- findings.push({
1704
- check: "channel-registry-consistency",
1705
- level: orphanFiles.length ? "warn" : "ok",
1706
- detail: orphanFiles.length ? `${orphanFiles.length} channel file(s) with no registry entry` : "channel files and registry agree",
1707
- fixable: true,
1708
- items: orphanFiles.length ? orphanFiles : undefined,
1709
- });
1710
- }
1711
- // 9b. Document scope drift (Phase 8 Task 4). For each document declared in
1712
- // scopes.json, compare git's last writer against the declared owner.
1713
- //
1714
- // DETECTION ONLY, never fixable — rewriting or reverting someone else's
1715
- // file is not a safe automatic repair, and the bus cannot prevent the
1716
- // write in the first place (agents edit these with ordinary file tools;
1717
- // enforcement waits for Task 5). Skips silently when no scopes.json
1718
- // exists (opt-in) or when the declared repo isn't a git checkout, the
1719
- // same way the wedged-pusher check skips without tmux — a check that
1720
- // can't observe anything must not guess.
1721
- {
1722
- const scopes = await loadScopes();
1723
- const drift = [];
1724
- const unattributed = [];
1725
- let detail;
1726
- if (!scopes.documents.length) {
1727
- detail = scopes.configured
1728
- ? `${path.basename(scopes.file)} declares no documents`
1729
- : `no ${path.basename(scopes.file)} — document scopes are opt-in and none are declared`;
1730
- }
1731
- else if (!isGitRepo(scopes.repo)) {
1732
- detail = `${scopes.documents.length} document(s) declared but '${scopes.repo}' is not a git checkout — last writer is unknowable, check skipped`;
1733
- }
1734
- else {
1735
- for (const doc of scopes.documents) {
1736
- const writer = lastWriterOf(scopes.repo, doc.path);
1737
- if (!writer)
1738
- continue; // never committed — nothing has written it yet
1739
- const who = attributeWriter(writer, reg);
1740
- if (!who) {
1741
- // Commits are authored by humans/machine accounts, not agent ids, so
1742
- // an unmappable author is the normal case — reported, never flagged.
1743
- unattributed.push(`${doc.path}: last written by '${writer.author}' (${writer.commit}), not attributable to a registered agent`);
1744
- continue;
1745
- }
1746
- if (ownsDocument(who.agentId, reg[who.agentId], doc.owner))
1747
- continue;
1748
- drift.push(`${doc.path}: declared owner '${doc.owner}' (${doc.mode}) but last written by '${who.agentId}'` +
1749
- `${who.roleId ? ` [role ${who.roleId}]` : ""} in ${writer.commit} (${writer.when})`);
1750
- }
1751
- detail = drift.length
1752
- ? `${drift.length} document(s) last written by someone other than their declared owner — advisory: coordinate ownership, doctor will not rewrite anyone's file`
1753
- : `${scopes.documents.length} declared document(s) agree with their scope`;
1754
- }
1755
- findings.push({
1756
- check: "document-scope-drift",
1757
- level: drift.length ? "warn" : "ok",
1758
- detail,
1759
- fixable: false,
1760
- items: drift.length ? drift : unattributed.length ? unattributed : undefined,
1761
- });
1762
- }
1763
- // 10. Environment sanity. Report only. Path + version come from the running
1764
- // module's package.json so a stale/decoy bin is named (LESSONS #35). Git
1765
- // identity is walk-up-to-.git + rev-parse (kit monorepo root); omitted
1766
- // entirely when there is no checkout — never invented.
1767
- {
1768
- const tmuxProbe = spawnSync("tmux", ["-V"]);
1769
- const tmuxOk = tmuxProbe.status === 0;
1770
- const ident = resolveServerIdentity();
1771
- const loc = ident.branch && ident.sha
1772
- ? `path=${ident.path} version=${ident.version} ${ident.branch}@${ident.sha.slice(0, 12)}`
1773
- : `path=${ident.path} version=${ident.version}`;
1774
- const items = [
1775
- `root=${ROOT}`,
1776
- `execPath=${process.execPath}`,
1777
- `inTmux=${!!process.env.TMUX_PANE}`,
1778
- `path=${ident.path}`,
1779
- `version=${ident.version}`,
1780
- ];
1781
- if (ident.branch)
1782
- items.push(`branch=${ident.branch}`);
1783
- if (ident.sha)
1784
- items.push(`sha=${ident.sha}`);
1785
- findings.push({
1786
- check: "environment",
1787
- level: tmuxOk ? "ok" : "warn",
1788
- detail: tmuxOk
1789
- ? `root=${ROOT}; node=${process.execPath}; ${loc}; tmux=${(tmuxProbe.stdout ?? "").toString().trim() || "present"}`
1790
- : `root=${ROOT}; node=${process.execPath}; ${loc}; tmux NOT on PATH — the tmux-push transport will not work`,
1791
- fixable: false,
1792
- items,
1793
- });
1794
- }
1795
- const summary = {
1796
- ok: findings.filter((f) => f.level === "ok").length,
1797
- warn: findings.filter((f) => f.level === "warn").length,
1798
- error: findings.filter((f) => f.level === "error").length,
1799
- };
1800
- return {
1801
- ok: true,
1802
- healthy: summary.warn === 0 && summary.error === 0,
1803
- fixApplied: fix,
1804
- root: ROOT,
1805
- findings,
1806
- fixed: fix ? fixed : undefined,
1807
- summary,
1808
- };
1809
- }
1810
- //# sourceMappingURL=transport.js.map