agent-coord-mcp 0.26.24 → 0.26.26

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 (60) hide show
  1. package/README.md +31 -70
  2. package/dist/capabilities.js +169 -4
  3. package/dist/capabilities.js.map +1 -1
  4. package/dist/gated-head.js +12 -1
  5. package/dist/gated-head.js.map +1 -1
  6. package/dist/server-spread.js +60 -53
  7. package/dist/server-spread.js.map +1 -1
  8. package/dist/server.js +53 -4
  9. package/dist/server.js.map +1 -1
  10. package/dist/tools/herdr-delivery.js +86 -26
  11. package/dist/tools/herdr-delivery.js.map +1 -1
  12. package/dist/tools/herdr-tail.js +243 -0
  13. package/dist/tools/herdr-tail.js.map +1 -0
  14. package/dist/tools/jsonl-offsets.js +37 -0
  15. package/dist/tools/jsonl-offsets.js.map +1 -0
  16. package/dist/tools/messaging.js +18 -0
  17. package/dist/tools/messaging.js.map +1 -1
  18. package/dist/tools/records.js +18 -2
  19. package/dist/tools/records.js.map +1 -1
  20. package/dist/tools/registry.js +56 -21
  21. package/dist/tools/registry.js.map +1 -1
  22. package/dist/tools/transport.js +17 -148
  23. package/dist/tools/transport.js.map +1 -1
  24. package/dist/transports/config.js +22 -11
  25. package/dist/transports/config.js.map +1 -1
  26. package/dist/transports/herdr.js +134 -18
  27. package/dist/transports/herdr.js.map +1 -1
  28. package/dist/transports/index.js +4 -3
  29. package/dist/transports/index.js.map +1 -1
  30. package/dist/transports/tmux.js +5 -2
  31. package/dist/transports/tmux.js.map +1 -1
  32. package/dist/transports/types.js +18 -3
  33. package/dist/transports/types.js.map +1 -1
  34. package/hooks/control-bytes.mjs +69 -0
  35. package/hooks/submit.mjs +227 -9
  36. package/hooks/tier.mjs +7 -1
  37. package/package.json +4 -2
  38. package/scripts/check-global-mcp-fallback.mjs +85 -0
  39. package/scripts/coord-pusher.mjs +5 -1
  40. package/scripts/coord-seat.mjs +96 -0
  41. package/scripts/coord-token.mjs +79 -4
  42. package/scripts/stop-agent.sh +8 -4
  43. package/src/capabilities.ts +170 -5
  44. package/src/gated-head.ts +12 -1
  45. package/src/server-spread.ts +52 -4
  46. package/src/server.ts +49 -3
  47. package/src/tools/herdr-delivery.ts +92 -24
  48. package/src/tools/herdr-tail.ts +276 -0
  49. package/src/tools/jsonl-offsets.ts +29 -0
  50. package/src/tools/messaging.ts +20 -0
  51. package/src/tools/records.ts +18 -2
  52. package/src/tools/registry.ts +56 -20
  53. package/src/tools/transport.ts +17 -154
  54. package/src/transports/config.ts +25 -12
  55. package/src/transports/herdr.ts +193 -17
  56. package/src/transports/index.ts +4 -3
  57. package/src/transports/tmux.ts +5 -2
  58. package/src/transports/types.ts +24 -4
  59. package/hooks/tmux-pusher.mjs +0 -967
  60. package/scripts/spawn-agent.sh +0 -94
@@ -0,0 +1,276 @@
1
+ /**
2
+ * ⛔ A HERDR SEAT WAS DEAF TO EVERY TMUX SEAT, AND THE REASON IS A SINGLETON.
3
+ *
4
+ * `deliverToHerdrSeats` runs at SEND time and returns early unless `activeTransport().kind` is
5
+ * HERDR. `activeTransport()` returns a module-level singleton wired once at startup from the
6
+ * SENDER's own config — so that early return can only ever see the SENDER's transport, and a tmux
7
+ * seat's server could never type into a herdr pane. Measured both ways: `herdr → herdr` typed and
8
+ * woke the seat; `tmux → herdr` reached the inbox file and was never typed.
9
+ *
10
+ * ⚠ THE EASIER FIX IS THE WRONG ONE: letting the SENDER drive herdr per recipient passes on a
11
+ * one-machine fleet and fails silently and asymmetrically on two, because every sender would need
12
+ * the `herdr` binary on its own host. So the duty goes to the seat that needs waking — the only
13
+ * party guaranteed to be where it is.
14
+ *
15
+ * ⛔⛆ AND THE FIRST VERSION OF THIS FILE WAS INERT ON EVERY SEAT, INCLUDING ITS AUTHOR'S.
16
+ * `8363968` started the tail only when `AGENT_COORD_BOUND_AGENT` was set at startup. No seat on
17
+ * this fleet sets it — identity binds at `join` — so the coordinator measured "herdr tail NOT
18
+ * started" on the PR's own server and qa read the variable unset on all six live seat servers.
19
+ * Its eleven tests injected the agent id and so could not see it. The tail now starts from the
20
+ * IDENTITY BINDING (`startTailOnBind`), idempotently, one per agent per process.
21
+ *
22
+ * ⛔ AND IT MUST NOT RUN THE REAPER. That version called `loadLiveTransports()` on every tick —
23
+ * the loader that DELETES markers it judges dead, named an hour earlier as the vanishing-marker
24
+ * mechanism. A reaper in a once-a-second loop on every seat is not shippable on the argument that
25
+ * the current build usually keeps the marker. This file reads its own seat's marker FILE directly,
26
+ * read-only, and never asks about anyone else's.
27
+ *
28
+ * THE CURSOR RULE IS THE PUSHER'S (⟨q-7be94b5e⟩): advance the PUSH cursor only past what was
29
+ * VERIFIABLY typed. Two corrections from qa's read of `8363968`, both now driven by tests:
30
+ * · the cursor advances to the byte AFTER THE DELIVERED LINE, never to the file's size — sizing
31
+ * it skipped a later message in the same tick whose paste then failed;
32
+ * · a cursor write that FAILS is held, and an in-process high-water mark stops the same message
33
+ * being re-typed on the next tick — the directory fix closed one cause of a paste loop, this
34
+ * closes the class.
35
+ *
36
+ * IDLE COST, COUNTED BY TESTS RATHER THAN ASSERTED HERE: when no tailed file has grown, a tick is
37
+ * one `stat()` per tailed file and nothing else. The marker and room membership are re-read every
38
+ * `REFRESH_TICKS` ticks, and no herdr subprocess runs until there is something to type.
39
+ */
40
+ import { readFileSync, statSync } from "node:fs";
41
+ import { inboxFile, roomFile, getRooms, transportFile } from "../store.js";
42
+ import { activeTransport, HERDR, type Transport, type TransportMarker } from "../transports/index.js";
43
+ import { renderForPane, advancePushCursor, readPushCursorFor, type Message } from "./herdr-delivery.js";
44
+ import { newMessagesIn } from "./jsonl-offsets.js";
45
+
46
+ export const DEFAULT_POLL_MS = 1000;
47
+ /** Marker and room membership are re-read this often. A room joined mid-session is picked up within this many ticks. */
48
+ export const REFRESH_TICKS = 30;
49
+
50
+ type Source = { kind: "dm" | "room"; chan?: string; file: string };
51
+ export type TailOutcome = {
52
+ delivered: { kind: "dm" | "room"; chan?: string; id: string }[];
53
+ held: { kind: "dm" | "room"; chan?: string; id: string; why: string }[];
54
+ idle?: string;
55
+ };
56
+
57
+ /** Per-agent memory that survives between ticks — what makes the idle path cheap and the paste loop impossible. */
58
+ export type TailContext = {
59
+ tick: number;
60
+ marker: TransportMarker | null;
61
+ rooms: string[];
62
+ /** In-process high-water mark per source: never re-type below this, even if the persistent cursor could not be written. */
63
+ hw: Map<string, number>;
64
+ /** Last seen size per source: a source that has not grown costs one stat. */
65
+ sizes: Map<string, number>;
66
+ /**
67
+ * ⛔ START AT EOF, NEVER AT WHATEVER THE CURSOR FILE HAPPENS TO SAY. A tail is a LIVE push
68
+ * mechanism, not a mail reader: nothing that predates its start belongs on the pane. Measured
69
+ * 2026-09-17 — a daemon restart reset every push cursor to ~0, so a tail starting from disk
70
+ * state would have typed a 3.4MB inbox into one seat's pane. `read_messages` still serves that
71
+ * history on demand; that is the verb for it.
72
+ */
73
+ seedEof: boolean;
74
+ /** Sources already seeded — a room discovered on a later refresh is seeded on ITS first sight, not skipped. */
75
+ seeded: Set<string>;
76
+ };
77
+ export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map(), seedEof, seeded: new Set() });
78
+
79
+ const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
80
+ const sizeOf = (file: string): number => {
81
+ try { return statSync(file).size; } catch { return 0; }
82
+ };
83
+
84
+ /** Read THIS seat's own marker, read-only. Never the reaping loader. */
85
+ export function readOwnMarker(agentId: string): TransportMarker | null {
86
+ try { return JSON.parse(readFileSync(transportFile(agentId), "utf8")) as TransportMarker; } catch { return null; }
87
+ }
88
+
89
+ /** The shared line reader — re-exported so callers of this module keep one import. */
90
+ export { newMessagesIn } from "./jsonl-offsets.js";
91
+
92
+ async function offsetOf(agentId: string, src: Source, ctx: TailContext): Promise<number> {
93
+ const c = await readPushCursorFor(agentId);
94
+ const persisted = src.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[src.chan!] ?? 0);
95
+ return Math.max(persisted, ctx.hw.get(keyOf(src)) ?? 0);
96
+ }
97
+
98
+ /** One pass. Everything that talks to the outside world is injectable, and nothing here schedules itself. */
99
+ export async function tailOnce(
100
+ agentId: string,
101
+ opts: {
102
+ transport?: Transport;
103
+ ctx?: TailContext;
104
+ markerOf?: (agentId: string) => TransportMarker | null;
105
+ roomsOf?: () => Promise<Record<string, { members?: string[] }>>;
106
+ } = {},
107
+ ): Promise<TailOutcome> {
108
+ const out: TailOutcome = { delivered: [], held: [] };
109
+ const t = opts.transport ?? activeTransport();
110
+ if (!t || t.kind !== HERDR) return { ...out, idle: "this server's transport is not herdr — the tail is for a herdr seat's own inbox" };
111
+ const ctx = opts.ctx ?? newContext();
112
+ const refresh = ctx.tick % REFRESH_TICKS === 0;
113
+ ctx.tick += 1;
114
+
115
+ // A seat with NO marker re-reads it only on the refresh cadence too. The coordinator's gate found
116
+ // `|| !ctx.marker` here, which made a markerless seat — the exact state a vanished marker leaves —
117
+ // read its marker file on every tick while the stated idle cost said otherwise.
118
+ if (refresh) {
119
+ ctx.marker = (opts.markerOf ?? readOwnMarker)(agentId);
120
+ if (ctx.marker && ctx.marker.transport === HERDR && ctx.marker.rooms !== false) {
121
+ try {
122
+ const all = await (opts.roomsOf ?? getRooms)();
123
+ ctx.rooms = Object.entries(all ?? {}).filter(([, r]) => (r?.members ?? []).includes(agentId)).map(([chan]) => chan);
124
+ } catch { ctx.rooms = []; }
125
+ } else {
126
+ ctx.rooms = [];
127
+ }
128
+ }
129
+ const marker = ctx.marker;
130
+ if (!marker || marker.transport !== HERDR) return { ...out, idle: `${agentId} has no herdr marker on this bus — nothing to type into (re-checked every ${REFRESH_TICKS} ticks)` };
131
+
132
+ const sources: Source[] = [{ kind: "dm", file: inboxFile(agentId) }, ...ctx.rooms.map((chan) => ({ kind: "room" as const, chan, file: roomFile(chan) }))];
133
+
134
+ for (const src of sources) {
135
+ const size = sizeOf(src.file);
136
+ const key = keyOf(src);
137
+ // ⭐ THE IDLE PATH: a source that has not grown since the last tick costs this one stat.
138
+ if (ctx.sizes.get(key) === size) continue;
139
+ ctx.sizes.set(key, size);
140
+ // ⛔ FIRST SIGHT OF A SOURCE UNDER seedEof: adopt EOF and type NOTHING. This is the only place
141
+ // the starting offset is decided, and it is decided EXPLICITLY rather than inherited from a
142
+ // cursor file that a restart may have reset. Per source, so a room joined later seeds on its
143
+ // own first sight instead of replaying its history.
144
+ if (ctx.seedEof && !ctx.seeded.has(key)) {
145
+ ctx.seeded.add(key);
146
+ ctx.hw.set(key, size);
147
+ continue;
148
+ }
149
+ const from = await offsetOf(agentId, src, ctx);
150
+ if (size <= from) continue;
151
+
152
+ for (const { msg, end } of newMessagesIn(src.file, from)) {
153
+ if (msg.from === agentId) { ctx.hw.set(key, end); continue; }
154
+ const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
155
+ const rendered = await renderForPane(msg as unknown as Message, agentId, where);
156
+ let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
157
+ try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
158
+ if (!r.delivered && r.safeToRetry === true) {
159
+ // HELD: the transport sent NO key (no ready input box, a draft, a dialog, an unreadable pane).
160
+ // Nothing moves and the message is retried next tick, which costs only a screen read. Stop
161
+ // this source so no later message is typed past one that did not land.
162
+ out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: r.error ?? "held — nothing was typed" });
163
+ ctx.sizes.delete(key); // force a re-read next tick
164
+ break;
165
+ }
166
+ // ⛔ ANYTHING SHORT OF A VERIFIED DELIVERY DOES NOT MOVE THE PERSISTENT CURSOR (⟨q-15d763dc⟩).
167
+ // Keys may have reached the pane (text typed, an Enter pressed) without the screen proving the
168
+ // message was submitted, so it stays owed on disk: read_messages still serves it and a restart
169
+ // re-offers it. The in-process high-water mark alone stops it being re-typed every tick, since
170
+ // re-typing a message that may already be sitting in the box is the paste loop in another coat.
171
+ // A throw lands here too: nothing says whether a key went out before it.
172
+ if (!r.delivered || r.verified !== true) {
173
+ ctx.hw.set(key, end);
174
+ out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: `${r.outcome ?? (r.delivered ? "unverified" : "not delivered")} — ${r.error ?? "not verified"}; the persistent cursor was not moved (q-15d763dc)` });
175
+ break;
176
+ }
177
+ // It landed and was verified. Record that in-process FIRST, so a cursor write that fails
178
+ // cannot turn one delivery into a paste loop, then persist exactly past this line.
179
+ ctx.hw.set(key, end);
180
+ const persisted = await advancePushCursor(agentId, where, end);
181
+ out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
182
+ if (!persisted) {
183
+ out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: "delivered, but the push cursor could not be written — held in memory so it is not re-typed; a restart before the next successful write may type it once more" });
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ // ─── the per-process registry of running tails ─────────────────────────────
192
+
193
+ type TailRecord = {
194
+ agentId: string;
195
+ startedAt: string;
196
+ ticks: number;
197
+ delivered: number;
198
+ held: number;
199
+ lastTickAt: string | null;
200
+ lastWhy: string | null;
201
+ stop: () => void;
202
+ };
203
+ const running = new Map<string, TailRecord>();
204
+
205
+ /** What `capabilities` reports: the tails THIS PROCESS is actually running, not the ones it could run. */
206
+ export function herdrTailState(): { agentId: string; running: true; startedAt: string; ticks: number; delivered: number; held: number; lastTickAt: string | null; lastWhy: string | null }[] {
207
+ return [...running.values()].map(({ stop: _stop, ...r }) => ({ ...r, running: true as const }));
208
+ }
209
+
210
+ export function stopHerdrTail(agentId: string): boolean {
211
+ const r = running.get(agentId);
212
+ if (!r) return false;
213
+ r.stop();
214
+ running.delete(agentId);
215
+ return true;
216
+ }
217
+ export function stopAllHerdrTails(): void {
218
+ for (const id of [...running.keys()]) stopHerdrTail(id);
219
+ }
220
+
221
+ /**
222
+ * Start the tail for `agentId` if this server's transport is herdr. IDEMPOTENT: a second bind of
223
+ * the same agent in the same process returns `running: true, started: false`.
224
+ *
225
+ * ⛔ NEVER OVERLAPS ITSELF: a tick still typing when the timer fires would interleave two pastes
226
+ * into one pane; the guard is one in-flight flag, because the next tick re-reads the cursor.
227
+ */
228
+ export function ensureHerdrTail(
229
+ agentId: string,
230
+ opts: { transport?: Transport; pollMs?: number; seedEof?: boolean; tail?: (id: string, ctx: TailContext) => Promise<TailOutcome> } = {},
231
+ ): { started: boolean; running: boolean; why: string } {
232
+ const t = opts.transport ?? activeTransport();
233
+ if (!t || t.kind !== HERDR) return { started: false, running: false, why: `transport is ${t?.kind ?? "unwired"}, not herdr — a tmux seat is woken by its pusher` };
234
+ if (running.has(agentId)) return { started: false, running: true, why: "already tailing this agent in this process" };
235
+ const ctx = newContext(opts.seedEof === true);
236
+ const run = opts.tail ?? ((id: string, c: TailContext) => tailOnce(id, { transport: t, ctx: c }));
237
+ const rec: TailRecord = { agentId, startedAt: new Date().toISOString(), ticks: 0, delivered: 0, held: 0, lastTickAt: null, lastWhy: null, stop: () => {} };
238
+ let inFlight = false;
239
+ const timer = setInterval(() => {
240
+ if (inFlight) return;
241
+ inFlight = true;
242
+ void run(agentId, ctx)
243
+ .then((o) => {
244
+ rec.ticks += 1;
245
+ rec.lastTickAt = new Date().toISOString();
246
+ rec.delivered += o.delivered.length;
247
+ rec.held += o.held.length;
248
+ rec.lastWhy = o.held.at(-1)?.why ?? o.idle ?? null;
249
+ })
250
+ .catch((e) => { rec.lastWhy = `tick threw: ${(e as Error).message}`; })
251
+ .finally(() => { inFlight = false; });
252
+ }, opts.pollMs ?? DEFAULT_POLL_MS);
253
+ timer.unref?.(); // optional-call: `unref` is absent on some host timer shims, and a server that cannot unref its tail must still start it
254
+ rec.stop = () => clearInterval(timer);
255
+ running.set(agentId, rec);
256
+ return { started: true, running: true, why: `tailing ${agentId}'s inbox and rooms every ${opts.pollMs ?? DEFAULT_POLL_MS}ms` };
257
+ }
258
+
259
+ /**
260
+ * The hook the server calls at EVERY identity binding — `join`, a gated first claim, the env var,
261
+ * or (HTTP) an authenticated request from a PRE-BOUND identity.
262
+ *
263
+ * ⛔ THE BINDING TRANSITION IS NOT REACHABLE UNDER HTTP, WHICH IS WHY THIS ALSO HANGS OFF AUTH.
264
+ * The `bound === undefined` sites below only fire when a session CLAIMS an id. A token-authenticated
265
+ * daemon resolves identity per request and reports `pre-bound (N agents)`, so `bound` is never
266
+ * undefined and NO tail ever started — measured 2026-09-17 as `herdrTail: []` on a freshly
267
+ * restarted daemon whose seats had re-joined after it. With the send-time path deferring owed
268
+ * messages to "the recipient's own tail", a tail that never exists makes that deferral a permanent
269
+ * silent drop: one message behind and the seat is deaf for good (⟨q-cdb5b007⟩).
270
+ *
271
+ * Idempotent, so the per-request call costs one Map lookup once the tail is up.
272
+ */
273
+ export function startTailOnBind(agentId: string, log: (line: string) => void = (l) => console.error(l)): void {
274
+ const r = ensureHerdrTail(agentId, { seedEof: true });
275
+ if (r.started) log(`[agent-coord-mcp] herdr tail: ${r.why}`);
276
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Messages appended to a JSONL bus file after byte `from`, each with the absolute byte offset just
3
+ * past its own line. Shared by the send-time herdr path and the seat's own tail, so both agree on
4
+ * exactly which bytes a delivery covers — a second reader would be a second truth about where a
5
+ * seat's cursor may move.
6
+ *
7
+ * A partial trailing line is left for the next read rather than guessed at, and a line that is not
8
+ * JSON is skipped with the offset moving past it.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+
12
+ export type OffsetMessage = { msg: Record<string, unknown>; end: number };
13
+
14
+ export function newMessagesIn(file: string, from: number): OffsetMessage[] {
15
+ let buf: Buffer;
16
+ try { buf = readFileSync(file); } catch { return []; }
17
+ const out: OffsetMessage[] = [];
18
+ let pos = Math.min(Math.max(0, from), buf.length);
19
+ while (pos < buf.length) {
20
+ const nl = buf.indexOf(0x0a, pos);
21
+ if (nl === -1) break;
22
+ const line = buf.subarray(pos, nl).toString("utf8");
23
+ const end = nl + 1;
24
+ pos = end;
25
+ if (!line.trim()) continue;
26
+ try { out.push({ msg: JSON.parse(line), end }); } catch { /* not a message; the offset moves past it */ }
27
+ }
28
+ return out;
29
+ }
@@ -20,6 +20,9 @@ import { readLog } from "./logwatch.js";
20
20
  // `dist/`, so this resolves the same in the built package as in source.
21
21
  // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
22
22
  import { priorDeliveries, replayInfo } from "../../hooks/replay.mjs";
23
+ // ⟨q-e5cb3538⟩ The pane-unsafe byte class is single-sourced there too, shared with the renderer.
24
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
25
+ import { findControlByte } from "../../hooks/control-bytes.mjs";
23
26
  import { renderRecord } from "./render.js";
24
27
  import path from "node:path";
25
28
  import {
@@ -344,6 +347,23 @@ export async function sendMessageTool(args: {
344
347
  const scribeCheck = checkVerdictScribe(args.from, args.record);
345
348
  if (!scribeCheck.ok) return { ok: false as const, error: scribeCheck.error };
346
349
 
350
+ // ⟨q-e5cb3538⟩ — INGRESS POLICY: REFUSE, don't rewrite. A control byte in content is a
351
+ // keystroke in every pane the message is typed into (CR submits, ESC interrupts, ETX is
352
+ // Ctrl-C). Refused rather than silently escaped because the stored message is what gets
353
+ // cited and replayed; a rewrite would make it differ from what the sender wrote, and the
354
+ // sender is the only party who can say what the byte was for. Line feed and tab pass.
355
+ // Render-time escaping (hooks/tier.mjs) still covers history stored before this check.
356
+ const control = findControlByte({ from: args.from, to: args.to, room: args.room, text: args.text, record: args.record, repo: args.repo });
357
+ if (control) {
358
+ return {
359
+ ok: false as const,
360
+ error:
361
+ `refused: '${control.path}' carries a control character (${control.escape}) at offset ${control.offset}. ` +
362
+ "Delivered to a pane it would be typed as a keystroke in the recipient's session. " +
363
+ "Remove it, or write it out visibly (e.g. \\r); quoted external text (PR bodies, commit messages) is the usual carrier. Nothing was written.",
364
+ };
365
+ }
366
+
347
367
  // A `done` must cite the work it claims. Presence and shape only — resolving
348
368
  // the ref against gh/git is a consumer's job, and the send path makes no
349
369
  // network calls. Rejected as a value, not a throw, mirroring the
@@ -45,7 +45,7 @@ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
45
45
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
46
46
  import { haltState } from "./stall.js";
47
47
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
48
- import { prRefsIn } from "./record-events.js";
48
+ import { prRefsIn, leadingItemIdOf } from "./record-events.js";
49
49
 
50
50
  const QUEUE_DOC = "docs/QUEUE.md";
51
51
  const DONE_DOC = "docs/DONE.md";
@@ -1199,7 +1199,23 @@ export async function landTool(
1199
1199
  }
1200
1200
  }
1201
1201
 
1202
- const already = doneEntriesOf(d.doc).some((e) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")));
1202
+ /*
1203
+ * ⛔ ⟨q-552b9912⟩ "ALREADY LOGGED" IS A QUESTION ABOUT THE ITEM, NOT THE PR.
1204
+ *
1205
+ * It asked whether ANY DONE entry cited this PR, so the second item a PR closed found the first item's entry,
1206
+ * reported `alreadyLogged: true`, and wrote nothing: the row left the queue and never reached the delivery record,
1207
+ * so `scan_record_events` emitted no item event for it. Measured twice on 2026-09-16 — ⟨q-3b8e05af⟩ on #360 and
1208
+ * ⟨q-144c97a8⟩ on #366, both hand-repaired (07338a1, 00c8315).
1209
+ *
1210
+ * Keyed per item BY POSITION ONLY: an entry is this item's when it LEADS with `⟨id⟩` (the form this verb writes). A
1211
+ * by-mention fallback was tried and REMOVED (qa's FAIL on #370 @ fce3ae1): an id-less entry for this PR that merely
1212
+ * MENTIONS the id — B never landed — read as logged and wrote nothing, the same defect narrowed. A mention is not a
1213
+ * record. A land with no item keeps the PR key, since there is no item to key on.
1214
+ */
1215
+ const citesThisPr = (e: { ref?: string }) => new RegExp(`#${n}\\b`).test(String(e.ref ?? ""));
1216
+ const already = target
1217
+ ? doneEntriesOf(d.doc).some((e) => leadingItemIdOf(e.text) === target.id)
1218
+ : doneEntriesOf(d.doc).some(citesThisPr);
1203
1219
 
1204
1220
  // A DONE LINE A HUMAN WOULD NOT HAVE WRITTEN IS NOT A DONE LINE.
1205
1221
  //
@@ -6,7 +6,7 @@ import { existsSync, openSync, statSync, watch } from "node:fs";
6
6
  import { promises as fsp } from "node:fs";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
- import { detectSpread, installedBuild } from "../server-spread.js";
9
+ import { detectSpread, installedBuild, pidRunning } from "../server-spread.js";
10
10
  import { z } from "zod";
11
11
  import path from "node:path";
12
12
  import {
@@ -161,6 +161,9 @@ export async function registerTool(args: {
161
161
  registeredAt: existing?.registeredAt ?? now,
162
162
  lastHeartbeat: now,
163
163
  capabilities: existing?.capabilities,
164
+ // ⟨q-18a719c5⟩ The entry is rebuilt here, so the stamp is rebuilt with it — from the
165
+ // server answering THIS register, which is the one serving the seat now.
166
+ ...answeringServerIdentity(),
164
167
  // Omitted → carried forward untouched. Only an explicit `false` revokes.
165
168
  ...(args.proseOnly === undefined
166
169
  ? existing?.proseOnly
@@ -285,6 +288,39 @@ export async function quitTool(args: { agentId: string }): Promise<never> {
285
288
 
286
289
  export const heartbeatSchema = { agentId: z.string().min(1) };
287
290
 
291
+ /**
292
+ * ⟨q-18a719c5⟩ THE ANSWERING SERVER'S IDENTITY — what `serverSpread` places a seat by. It exists
293
+ * only inside the process that serves the seat, so every path on which a session binds an
294
+ * identity stamps it: `register` (which `join` calls), the first-claim binding in server.ts
295
+ * (a server that restarts and re-binds through any gated tool, `attach_agent` included),
296
+ * `rename_agent` (the renaming session serves the new id), and `heartbeat`. Before this,
297
+ * only `heartbeat` stamped, no code path called it, and `register` rebuilt the entry and
298
+ * dropped whatever it had written.
299
+ */
300
+ export function answeringServerIdentity(): { serverPid: number; serverStartedAt: number; serverModule?: string } {
301
+ return {
302
+ serverPid: process.pid,
303
+ // Derived from uptime rather than read from a file: an mtime tracks writes (a reinstall of
304
+ // identical bytes moves it) while uptime is a fact about THIS process.
305
+ serverStartedAt: Date.now() - Math.round(process.uptime() * 1000),
306
+ // WHAT this process is executing, not what it is labelled. Resolved from this module's own
307
+ // URL, so a server running a dev `dist/` says so instead of inheriting the installed path.
308
+ serverModule: installedBuild(import.meta.url, statSync)?.module,
309
+ };
310
+ }
311
+
312
+ /** Stamp an EXISTING registry entry with the answering server's identity. False when absent. */
313
+ export async function stampServerIdentity(agentId: string): Promise<boolean> {
314
+ let stamped = false;
315
+ await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
316
+ if (!current[agentId]) return current;
317
+ Object.assign(current[agentId], answeringServerIdentity());
318
+ stamped = true;
319
+ return current;
320
+ });
321
+ return stamped;
322
+ }
323
+
288
324
  export async function heartbeatTool(args: { agentId: string }) {
289
325
  let missing = false;
290
326
  await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
@@ -293,23 +329,8 @@ export async function heartbeatTool(args: { agentId: string }) {
293
329
  return current;
294
330
  }
295
331
  current[args.agentId].lastHeartbeat = Date.now();
296
- /*
297
- * ⛔ STAMP THE ANSWERING PROCESS — `⟨q-cec42e20⟩`. This runs INSIDE the server that
298
- * is answering, which is the only place these two facts exist: `capabilities`
299
- * reports them solely to its own caller, and no seat can query another seat's
300
- * server at all. Publishing them here is what makes a cross-seat comparison
301
- * possible without every seat having to volunteer it in prose.
302
- *
303
- * `startedAt` is derived from uptime rather than read from a file: an mtime tracks
304
- * writes (a reinstall of identical bytes moves it) while uptime is a fact about
305
- * THIS process.
306
- */
307
- current[args.agentId].serverPid = process.pid;
308
- current[args.agentId].serverStartedAt = Date.now() - Math.round(process.uptime() * 1000);
309
- // ⛔ WHAT this process is executing, not what it is labelled. Resolved from this
310
- // module's own URL, so a server running a dev `dist/` says so instead of inheriting
311
- // the installed path — the case that made a 3-build fleet read AGREED.
312
- current[args.agentId].serverModule = installedBuild(import.meta.url, statSync)?.module;
332
+ // ⛔ STAMP THE ANSWERING PROCESS — `⟨q-cec42e20⟩`; see `answeringServerIdentity`.
333
+ Object.assign(current[args.agentId], answeringServerIdentity());
313
334
  return current;
314
335
  });
315
336
  if (missing) return { ok: false, error: `agent '${args.agentId}' not registered` };
@@ -464,6 +485,8 @@ export async function listAgentsTool() {
464
485
  serverModule: a.serverModule,
465
486
  })),
466
487
  installedBuild(import.meta.url, statSync),
488
+ // ⟨q-18a719c5⟩ a stamp whose server is gone reads unknown: ask the kernel, here, in production.
489
+ { isRunning: pidRunning },
467
490
  );
468
491
 
469
492
  // ⟨q-178878aa⟩ — the humans the bus knows: visible here, read at send time, never evicted.
@@ -541,6 +564,18 @@ export function isMarkerLive(marker: TransportMarker, reg: AgentRegistry, now: n
541
564
  return isPidAlive(marker.pid);
542
565
  }
543
566
 
567
+ /**
568
+ * ⟨q-abd88dd4⟩ Does this marker hold a LIVE LOCAL PROCESS — a pusher someone could signal, or
569
+ * wait on? A herdr marker never does: it has no pusher, and its `pid` is addressed to pre-herdr
570
+ * readers (pid 1, see `herdrMarkerPid`), so reading it as a process would make pid 1 look like a
571
+ * running pusher. Every pid-as-process decision about a marker goes through here; liveness of a
572
+ * herdr seat is `isMarkerLive`, which asks herdr for the pane.
573
+ */
574
+ export function markerHoldsLiveProcess(marker: TransportMarker | null | undefined): boolean {
575
+ if (!marker || marker.transport === HERDR) return false;
576
+ return isPidAlive(marker.pid);
577
+ }
578
+
544
579
  export function isPidAlive(pid: number): boolean {
545
580
  if (!pid || pid <= 0) return false;
546
581
  try {
@@ -682,7 +717,7 @@ export async function renameAgentTool(args: { agentId: string; newAgentId: strin
682
717
  // must re-attach under the new id (join/attach_agent) to restore push.
683
718
  const liveTransport = await readJson<TransportMarker | null>(transportFile(oldId), null);
684
719
  let detachedTransport = false;
685
- if (liveTransport && isPidAlive(liveTransport.pid)) {
720
+ if (markerHoldsLiveProcess(liveTransport)) {
686
721
  await detachAgentTool({ agentId: oldId });
687
722
  detachedTransport = true;
688
723
  }
@@ -690,7 +725,8 @@ export async function renameAgentTool(args: { agentId: string; newAgentId: strin
690
725
  // Registry: move the entry under the new key.
691
726
  await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
692
727
  if (current[oldId]) {
693
- current[newId] = { ...current[oldId], agentId: newId };
728
+ // ⟨q-18a719c5⟩ The renaming session serves the new id, so the stamp is re-taken from it.
729
+ current[newId] = { ...current[oldId], agentId: newId, ...answeringServerIdentity() };
694
730
  delete current[oldId];
695
731
  }
696
732
  return current;