agent-coord-mcp 0.26.24 → 0.26.25

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 (47) hide show
  1. package/dist/capabilities.js +129 -4
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/gated-head.js +12 -1
  4. package/dist/gated-head.js.map +1 -1
  5. package/dist/server-spread.js +60 -53
  6. package/dist/server-spread.js.map +1 -1
  7. package/dist/server.js +41 -2
  8. package/dist/server.js.map +1 -1
  9. package/dist/tools/herdr-delivery.js +86 -26
  10. package/dist/tools/herdr-delivery.js.map +1 -1
  11. package/dist/tools/herdr-tail.js +221 -0
  12. package/dist/tools/herdr-tail.js.map +1 -0
  13. package/dist/tools/jsonl-offsets.js +37 -0
  14. package/dist/tools/jsonl-offsets.js.map +1 -0
  15. package/dist/tools/messaging.js +18 -0
  16. package/dist/tools/messaging.js.map +1 -1
  17. package/dist/tools/records.js +18 -2
  18. package/dist/tools/records.js.map +1 -1
  19. package/dist/tools/registry.js +56 -21
  20. package/dist/tools/registry.js.map +1 -1
  21. package/dist/tools/transport.js +4 -3
  22. package/dist/tools/transport.js.map +1 -1
  23. package/dist/transports/herdr.js +118 -18
  24. package/dist/transports/herdr.js.map +1 -1
  25. package/dist/transports/index.js +1 -1
  26. package/dist/transports/index.js.map +1 -1
  27. package/dist/transports/types.js.map +1 -1
  28. package/hooks/control-bytes.mjs +69 -0
  29. package/hooks/submit.mjs +227 -9
  30. package/hooks/tier.mjs +7 -1
  31. package/hooks/tmux-pusher.mjs +5 -1
  32. package/package.json +1 -1
  33. package/scripts/coord-pusher.mjs +5 -1
  34. package/src/capabilities.ts +129 -3
  35. package/src/gated-head.ts +12 -1
  36. package/src/server-spread.ts +52 -4
  37. package/src/server.ts +38 -1
  38. package/src/tools/herdr-delivery.ts +92 -24
  39. package/src/tools/herdr-tail.ts +244 -0
  40. package/src/tools/jsonl-offsets.ts +29 -0
  41. package/src/tools/messaging.ts +20 -0
  42. package/src/tools/records.ts +18 -2
  43. package/src/tools/registry.ts +56 -20
  44. package/src/tools/transport.ts +5 -4
  45. package/src/transports/herdr.ts +175 -17
  46. package/src/transports/index.ts +1 -1
  47. package/src/transports/types.ts +5 -0
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
1
3
  /*
2
4
  * ⛔⛆⛆ TWO SEATS RAN THE SAME VERB, GOT `153` AND `138`, AND BOTH SERVERS HONESTLY
3
5
  * REPORTED `versionLabel 0.26.20` — `⟨q-cec42e20⟩`.
@@ -95,9 +97,19 @@ const sideOf = (startedAt: number, installMtime: number): Cohort["side"] =>
95
97
  * read agrees. The cohorts are still returned, so a reader sees what was established as
96
98
  * well as what was not.
97
99
  */
100
+ /** Is a stamped server pid still running? EPERM means it exists and is not ours. */
101
+ export function pidRunning(pid: number): boolean {
102
+ if (!Number.isInteger(pid) || pid <= 0) return false;
103
+ try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === "EPERM"; }
104
+ }
105
+
98
106
  export function detectSpread(
99
107
  identities: ServerIdentity[],
100
108
  installed: InstalledBuild | null,
109
+ // PURE BY DEFAULT: the detector trusts a stamped pid unless the caller supplies a liveness
110
+ // check. The production caller (`list_agents`) passes `pidRunning`; a fixture of a measured
111
+ // incident, whose pids belong to processes long gone, is still placed by its stamps.
112
+ { isRunning = () => true }: { isRunning?: (pid: number) => boolean } = {},
101
113
  ): SpreadVerdict {
102
114
  const uncomparable: Uncomparable[] = [];
103
115
  const placed: { agentId: string; side: Cohort["side"]; module?: string }[] = [];
@@ -120,6 +132,16 @@ export function detectSpread(
120
132
  });
121
133
  continue;
122
134
  }
135
+ // ⟨q-18a719c5⟩ A STAMP OUTLIVES ITS SERVER. If the process that stamped this entry is gone,
136
+ // the module it names is what a DEAD process ran — not what serves the seat now. Unknown,
137
+ // naming the pid, until the seat's next server binds and stamps over it.
138
+ if (typeof id.serverPid === "number" && !isRunning(id.serverPid)) {
139
+ uncomparable.push({
140
+ agentId: id.agentId,
141
+ why: `stamped by server pid ${id.serverPid}, which is no longer running — the entry names a process that is gone, not the one serving this seat`,
142
+ });
143
+ continue;
144
+ }
123
145
  if (installed === null) continue;
124
146
  // ⭐ A DIFFERENT BUILD IS ITS OWN COHORT, never folded into `pre-install`: "which
125
147
  // side of the install did you start" is not a question about a process that does
@@ -156,6 +178,12 @@ export function detectSpread(
156
178
  uncomparable,
157
179
  };
158
180
  }
181
+ // ⟨q-18a719c5⟩ UNKNOWNS BLOCK AGREEMENT, NOT DISAGREEMENT. Two readable seats on different
182
+ // sides of the install is established whatever the unreadable ones run, so it is reported as
183
+ // DIVERGED with the unknowns listed. Only AGREED needs every seat, and that rule (#293) stands.
184
+ if (uncomparable.length && cohorts.length > 1) {
185
+ return { state: "DIVERGED", cohorts, comparable, uncomparable };
186
+ }
159
187
  if (uncomparable.length) {
160
188
  return {
161
189
  state: "CANNOT_COMPARE",
@@ -218,15 +246,35 @@ export function reportSpread(v: SpreadVerdict, installed: InstalledBuild | null)
218
246
  * question is "what build is the answering process running", and the answering process
219
247
  * is the one executing this file.
220
248
  */
249
+ export const COORD_MCP_PACKAGE_NAME = "agent-coord-mcp";
250
+
221
251
  export function installedBuild(
222
252
  fromUrl: string,
223
253
  statSync: (p: string) => { mtimeMs: number },
254
+ readFile: (p: string) => string = (p) => readFileSync(p, "utf8"),
224
255
  ): InstalledBuild | null {
256
+ // ⟨q-18a719c5⟩ THE ROOT IS FOUND BY THE PACKAGE'S OWN IDENTITY, not by a depth. This used to
257
+ // strip one level (`/dist/<file>`), and both production callers live in
258
+ // `dist/tools/registry.js`: the regex never matched, `statSync(".../registry.js/package.json")`
259
+ // threw, and this returned null on every server — `serverSpread` read CANNOT_COMPARE on every
260
+ // fleet and the heartbeat stamp wrote `serverModule: undefined`. A depth count, or the FIRST
261
+ // package.json found, breaks in other layouts (a global npm or homebrew install nests the
262
+ // package under node_modules, a workspace nests it under packages/); the package.json whose
263
+ // `name` is agent-coord-mcp is the package in every one of them. Nothing found stays null.
225
264
  try {
226
- // dist/server-spread.js -> the package root two levels up
227
- const here = new URL(fromUrl).pathname;
228
- const root = here.replace(/\/dist\/[^/]*$/, "");
229
- return { mtime: statSync(`${root}/package.json`).mtimeMs, module: root };
265
+ let dir = path.dirname(decodeURIComponent(new URL(fromUrl).pathname));
266
+ for (let hops = 0; hops < 12; hops++) {
267
+ const pkg = path.join(dir, "package.json");
268
+ try {
269
+ if (JSON.parse(readFile(pkg))?.name === COORD_MCP_PACKAGE_NAME) {
270
+ return { mtime: statSync(pkg).mtimeMs, module: dir };
271
+ }
272
+ } catch { /* no readable package.json here — keep walking */ }
273
+ const up = path.dirname(dir);
274
+ if (up === dir) break;
275
+ dir = up;
276
+ }
277
+ return null;
230
278
  } catch {
231
279
  return null;
232
280
  }
package/src/server.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
3
+ import { readyProfileStartupLine } from "../hooks/submit.mjs";
2
4
  import { randomUUID } from "node:crypto";
3
- import { initTransportFromConfig } from "./transports/index.js";
5
+ import { HERDR, initTransportFromConfig } from "./transports/index.js";
6
+ import { startTailOnBind, stopHerdrTail } from "./tools/herdr-tail.js";
4
7
  import { createServer, IncomingMessage, ServerResponse } from "node:http";
5
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -37,6 +40,7 @@ import {
37
40
  forceUnregisterTool,
38
41
  heartbeatSchema,
39
42
  heartbeatTool,
43
+ stampServerIdentity,
40
44
  joinRoomSchema,
41
45
  joinRoomTool,
42
46
  joinSchema,
@@ -334,6 +338,12 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
334
338
  const via = await guardFirstClaim(claimed, args);
335
339
  bound = claimed;
336
340
  recordSessionBinding(claimed, via);
341
+ // ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
342
+ startTailOnBind(claimed);
343
+ // ⟨q-18a719c5⟩ A server that re-binds WITHOUT `join` (a restart that first calls
344
+ // attach_agent, or any gated tool) serves the seat from now on: stamp the entry if it
345
+ // exists. A seat not yet registered is stamped by the `register` that creates it.
346
+ await stampServerIdentity(claimed);
337
347
  }
338
348
  } else if (bound !== claimed) {
339
349
  throw new Error(
@@ -384,6 +394,8 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
384
394
  const via = await guardFirstClaim(claimed, args);
385
395
  bound = claimed;
386
396
  recordSessionBinding(claimed, via);
397
+ // ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
398
+ startTailOnBind(claimed);
387
399
  } else if (bound !== claimed) {
388
400
  throw new Error(
389
401
  `identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
@@ -550,6 +562,8 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
550
562
  const via = await guardFirstClaim(claimed, args);
551
563
  bound = claimed;
552
564
  recordSessionBinding(claimed, via);
565
+ // ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
566
+ startTailOnBind(claimed);
553
567
  } else if (bound !== claimed) {
554
568
  throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
555
569
  }
@@ -558,8 +572,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
558
572
  if (result && typeof result === "object" && (result as { ok?: unknown }).ok === true) {
559
573
  const to = (result as { to?: unknown }).to;
560
574
  if (typeof to === "string") {
575
+ const from = bound;
561
576
  bound = to;
562
577
  recordSessionBinding(to, "rename");
578
+ // ⛔ THE TAIL MOVES WITH THE IDENTITY. The coordinator's gate measured a renamed herdr seat
579
+ // still tailing its OLD id's inbox — `herdrTail` listed only the old name — so the seat
580
+ // went deaf under its new one. Stop the old, start the new.
581
+ if (from && from !== to) stopHerdrTail(from);
582
+ startTailOnBind(to);
563
583
  }
564
584
  }
565
585
  return jsonResult(result);
@@ -828,6 +848,23 @@ async function main() {
828
848
  if (t.source !== "default") {
829
849
  console.error(`[agent-coord-mcp] transport: ${t.kind} (from ${t.source})`);
830
850
  }
851
+ // ⛔ A HERDR SEAT MUST TAIL ITS OWN INBOX, OR IT IS DEAF TO EVERY TMUX SENDER.
852
+ // The send-time path can only see the SENDER's transport (a module-level singleton), so a
853
+ // tmux seat's server can never type into a herdr pane. The duty belongs to the seat that
854
+ // needs waking — the only party guaranteed to be where it is — and this is where it starts.
855
+ // The env var is ONE way to bind identity, and no seat on this fleet uses it — they bind at
856
+ // `join`. So this branch only covers a server launched pre-bound; the tail's real start is the
857
+ // binding hook below, which runs at every bind. A herdr server with neither simply waits.
858
+ // ⟨q-15d763dc⟩ A herdr server types into panes itself, so an unguarded one says so at start.
859
+ if (t.kind === HERDR) {
860
+ const guardLine = readyProfileStartupLine("agent-coord-mcp");
861
+ if (guardLine) console.error(guardLine.trimEnd());
862
+ }
863
+ if (t.kind === HERDR && process.env.AGENT_COORD_BOUND_AGENT) {
864
+ startTailOnBind(process.env.AGENT_COORD_BOUND_AGENT);
865
+ } else if (t.kind === HERDR) {
866
+ console.error("[agent-coord-mcp] herdr transport: the inbox tail starts when this session binds its identity (join)");
867
+ }
831
868
  } catch (e) {
832
869
  // Loud, and fatal. A server that starts with an unusable transport config
833
870
  // delivers nothing while reporting itself up, and a quiet transport is the
@@ -20,13 +20,15 @@
20
20
  * `[agent-coord] …` banner and the attributed line) but one at a time. Tiers still decide
21
21
  * the banner text. Batching for herdr is Task 5 territory if it is wanted.
22
22
  */
23
- import { statSync } from "node:fs";
23
+ import { mkdirSync, statSync } from "node:fs";
24
24
  import path from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
  import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
27
27
  import { ROOT, inboxFile, roomFile } from "../store.js";
28
28
  import { loadLiveTransports } from "./registry.js";
29
+ import { newMessagesIn } from "./jsonl-offsets.js";
29
30
  import type { Message } from "./shared.js";
31
+ export type { Message };
30
32
 
31
33
  type Tier = { formatBatch: (batch: unknown[], agentId: string, rooms: string[]) => string; classifyTier: (m: unknown, opts?: unknown) => string };
32
34
  type PushCursor = { readPushCursor: (root: string, safeId: string) => Record<string, unknown>; writePushCursor: (root: string, safeId: string, c: Record<string, unknown>) => void };
@@ -42,7 +44,64 @@ function loadHooks(): Promise<{ tier: Tier; cursor: PushCursor }> {
42
44
  }
43
45
  const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
44
46
 
45
- export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean };
47
+
48
+ export type Where = { kind: "dm" } | { kind: "room"; chan: string };
49
+
50
+ /**
51
+ * ⭐ ONE RENDERER AND ONE CURSOR RULE, SHARED BY BOTH PATHS. The send-time path
52
+ * (`deliverToHerdrSeats`) and the seat's own tail (`herdr-tail.ts`) must produce the same bytes
53
+ * in the pane and must move the same cursor the same way — a second copy of either is a second
54
+ * truth about what a seat has already seen, and the copy is the one that rots.
55
+ */
56
+ export async function renderForPane(msg: Message, agentId: string, where: Where): Promise<string> {
57
+ const { tier } = await loadHooks();
58
+ const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` };
59
+ return tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []);
60
+ }
61
+
62
+ /**
63
+ * Advance the PUSH cursor past everything currently in the source. Returns whether it moved.
64
+ * ⛔ Called ONLY after a verified delivery — that is the rule this whole module exists to keep.
65
+ */
66
+ export async function advancePushCursor(agentId: string, where: Where, toOffset?: number): Promise<boolean> {
67
+ try {
68
+ const { cursor } = await loadHooks();
69
+ // ⛔ THE CURSOR DIRECTORY MUST EXIST OR THE WRITE FAILS SILENTLY, AND A CURSOR THAT NEVER
70
+ // ADVANCES RE-TYPES THE SAME MESSAGE ON EVERY TICK — a paste loop into a live pane. Found by
71
+ // the tail's own double-delivery test against a fresh bus directory, where `cursors/` had not
72
+ // been created yet: `writePushCursor` threw ENOENT, the `catch` below turned it into
73
+ // `false`, and the delivery looked fine from every other angle.
74
+ mkdirSync(path.join(ROOT, "cursors"), { recursive: true });
75
+ const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
76
+ // `toOffset` is the byte just past the line that was delivered. Without it (the send-time path,
77
+ // where the just-appended message IS the last line) the file size is the same number.
78
+ const size = toOffset ?? statSync(file).size;
79
+ const id = safeId(agentId);
80
+ const c = cursor.readPushCursor(ROOT, id) ?? {};
81
+ if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
82
+ else {
83
+ const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
84
+ offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
85
+ c.roomOffsets = offsets;
86
+ }
87
+ cursor.writePushCursor(ROOT, id, c);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** The push cursor as the pusher stores it, for a caller that needs to read an offset. */
95
+ export async function readPushCursorFor(agentId: string): Promise<Record<string, unknown>> {
96
+ try {
97
+ const { cursor } = await loadHooks();
98
+ return cursor.readPushCursor(ROOT, safeId(agentId)) ?? {};
99
+ } catch {
100
+ return {};
101
+ }
102
+ }
103
+
104
+ export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean; deferred?: string };
46
105
 
47
106
  /** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
48
107
  export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
@@ -56,32 +115,41 @@ export async function deliverToHerdrSeats(msg: Message, recipients: string[], wh
56
115
  if (!marker || marker.transport !== HERDR) continue;
57
116
  if (where.kind === "room" && marker.rooms === false) continue;
58
117
  if (msg.from === agentId) continue;
59
- const { tier, cursor } = await loadHooks();
60
- const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` };
61
- const rendered = tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []);
118
+ // ⛔ ORDER BEFORE SPEED. This path runs in the SENDER's server at send time. If an EARLIER
119
+ // message to this seat is still owed — a paste that failed, which the seat's own tail is
120
+ // retrying — typing this one now and moving the cursor would jump past the owed one, and the
121
+ // tail's retry would find the cursor already beyond it: that message would never be typed.
122
+ // The coordinator's gate named the old call as "still advances the cursor to EOF"; measured,
123
+ // it did exactly that. So: find THIS message after the persisted cursor; if any earlier
124
+ // message to someone else precedes it, defer to the recipient's tail, which delivers in order.
125
+ const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
126
+ const c = await readPushCursorFor(agentId);
127
+ const from = where.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[where.chan] ?? 0);
128
+ const after = newMessagesIn(file, from);
129
+ const at = after.findIndex((m) => m.msg.id === msg.id);
130
+ const owed = at === -1 ? [] : after.slice(0, at).filter((m) => m.msg.from !== agentId);
131
+ if (at === -1 || owed.length > 0) {
132
+ out.push({
133
+ agentId,
134
+ delivered: false,
135
+ cursorAdvanced: false,
136
+ deferred: at === -1
137
+ ? "this message is not after the push cursor (already delivered, or the file moved) — left to the recipient's tail"
138
+ : `${owed.length} earlier message(s) to this seat are still owed — left to the recipient's own tail, which delivers in order`,
139
+ });
140
+ continue;
141
+ }
142
+ const rendered = await renderForPane(msg, agentId, where);
62
143
  // A control never comes through here: send_command hands a herdr seat's control to the
63
144
  // transport's sendControl directly (a raw-vs-rendered branch here was dead and its
64
145
  // mutation survived, so it is gone). Everything delivered here is a rendered message.
65
- let r: { delivered: boolean; error?: string; enters?: number };
146
+ let r: { delivered: boolean; error?: string; enters?: number; verified?: boolean };
66
147
  try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
67
- let cursorAdvanced = false;
68
- if (r.delivered) {
69
- try {
70
- const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
71
- const size = statSync(file).size;
72
- const id = safeId(agentId);
73
- const c = cursor.readPushCursor(ROOT, id) ?? {};
74
- if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
75
- else {
76
- const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
77
- offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
78
- c.roomOffsets = offsets;
79
- }
80
- cursor.writePushCursor(ROOT, id, c);
81
- cursorAdvanced = true;
82
- } catch { cursorAdvanced = false; }
83
- }
84
- out.push({ agentId, delivered: r.delivered, enters: r.enters, error: r.error, cursorAdvanced });
148
+ // ⟨q-15d763dc⟩ Only a VERIFIED delivery moves the cursor; anything less leaves the message owed.
149
+ const verified = r.delivered && r.verified === true;
150
+ // Exactly past THIS message's line — never to the end of the file, which may already hold a later one.
151
+ const cursorAdvanced = verified ? await advancePushCursor(agentId, where, after[at]!.end) : false;
152
+ out.push({ agentId, delivered: verified, enters: r.enters, error: r.error ?? (r.delivered ? "delivered but not verified — cursor held (q-15d763dc)" : undefined), cursorAdvanced });
85
153
  }
86
154
  return out;
87
155
  }
@@ -0,0 +1,244 @@
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
+ export const newContext = (): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map() });
68
+
69
+ const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
70
+ const sizeOf = (file: string): number => {
71
+ try { return statSync(file).size; } catch { return 0; }
72
+ };
73
+
74
+ /** Read THIS seat's own marker, read-only. Never the reaping loader. */
75
+ export function readOwnMarker(agentId: string): TransportMarker | null {
76
+ try { return JSON.parse(readFileSync(transportFile(agentId), "utf8")) as TransportMarker; } catch { return null; }
77
+ }
78
+
79
+ /** The shared line reader — re-exported so callers of this module keep one import. */
80
+ export { newMessagesIn } from "./jsonl-offsets.js";
81
+
82
+ async function offsetOf(agentId: string, src: Source, ctx: TailContext): Promise<number> {
83
+ const c = await readPushCursorFor(agentId);
84
+ const persisted = src.kind === "dm" ? Number(c.inboxOffset ?? 0) : Number(((c.roomOffsets as Record<string, number> | undefined) ?? {})[src.chan!] ?? 0);
85
+ return Math.max(persisted, ctx.hw.get(keyOf(src)) ?? 0);
86
+ }
87
+
88
+ /** One pass. Everything that talks to the outside world is injectable, and nothing here schedules itself. */
89
+ export async function tailOnce(
90
+ agentId: string,
91
+ opts: {
92
+ transport?: Transport;
93
+ ctx?: TailContext;
94
+ markerOf?: (agentId: string) => TransportMarker | null;
95
+ roomsOf?: () => Promise<Record<string, { members?: string[] }>>;
96
+ } = {},
97
+ ): Promise<TailOutcome> {
98
+ const out: TailOutcome = { delivered: [], held: [] };
99
+ const t = opts.transport ?? activeTransport();
100
+ 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" };
101
+ const ctx = opts.ctx ?? newContext();
102
+ const refresh = ctx.tick % REFRESH_TICKS === 0;
103
+ ctx.tick += 1;
104
+
105
+ // A seat with NO marker re-reads it only on the refresh cadence too. The coordinator's gate found
106
+ // `|| !ctx.marker` here, which made a markerless seat — the exact state a vanished marker leaves —
107
+ // read its marker file on every tick while the stated idle cost said otherwise.
108
+ if (refresh) {
109
+ ctx.marker = (opts.markerOf ?? readOwnMarker)(agentId);
110
+ if (ctx.marker && ctx.marker.transport === HERDR && ctx.marker.rooms !== false) {
111
+ try {
112
+ const all = await (opts.roomsOf ?? getRooms)();
113
+ ctx.rooms = Object.entries(all ?? {}).filter(([, r]) => (r?.members ?? []).includes(agentId)).map(([chan]) => chan);
114
+ } catch { ctx.rooms = []; }
115
+ } else {
116
+ ctx.rooms = [];
117
+ }
118
+ }
119
+ const marker = ctx.marker;
120
+ 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)` };
121
+
122
+ const sources: Source[] = [{ kind: "dm", file: inboxFile(agentId) }, ...ctx.rooms.map((chan) => ({ kind: "room" as const, chan, file: roomFile(chan) }))];
123
+
124
+ for (const src of sources) {
125
+ const size = sizeOf(src.file);
126
+ const key = keyOf(src);
127
+ // ⭐ THE IDLE PATH: a source that has not grown since the last tick costs this one stat.
128
+ if (ctx.sizes.get(key) === size) continue;
129
+ ctx.sizes.set(key, size);
130
+ const from = await offsetOf(agentId, src, ctx);
131
+ if (size <= from) continue;
132
+
133
+ for (const { msg, end } of newMessagesIn(src.file, from)) {
134
+ if (msg.from === agentId) { ctx.hw.set(key, end); continue; }
135
+ const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
136
+ const rendered = await renderForPane(msg as unknown as Message, agentId, where);
137
+ let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
138
+ try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
139
+ if (!r.delivered && r.safeToRetry === true) {
140
+ // HELD: the transport sent NO key (no ready input box, a draft, a dialog, an unreadable pane).
141
+ // Nothing moves and the message is retried next tick, which costs only a screen read. Stop
142
+ // this source so no later message is typed past one that did not land.
143
+ out.held.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? ""), why: r.error ?? "held — nothing was typed" });
144
+ ctx.sizes.delete(key); // force a re-read next tick
145
+ break;
146
+ }
147
+ // ⛔ ANYTHING SHORT OF A VERIFIED DELIVERY DOES NOT MOVE THE PERSISTENT CURSOR (⟨q-15d763dc⟩).
148
+ // Keys may have reached the pane (text typed, an Enter pressed) without the screen proving the
149
+ // message was submitted, so it stays owed on disk: read_messages still serves it and a restart
150
+ // re-offers it. The in-process high-water mark alone stops it being re-typed every tick, since
151
+ // re-typing a message that may already be sitting in the box is the paste loop in another coat.
152
+ // A throw lands here too: nothing says whether a key went out before it.
153
+ if (!r.delivered || r.verified !== true) {
154
+ ctx.hw.set(key, end);
155
+ 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)` });
156
+ break;
157
+ }
158
+ // It landed and was verified. Record that in-process FIRST, so a cursor write that fails
159
+ // cannot turn one delivery into a paste loop, then persist exactly past this line.
160
+ ctx.hw.set(key, end);
161
+ const persisted = await advancePushCursor(agentId, where, end);
162
+ out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
163
+ if (!persisted) {
164
+ 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" });
165
+ break;
166
+ }
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ // ─── the per-process registry of running tails ─────────────────────────────
173
+
174
+ type TailRecord = {
175
+ agentId: string;
176
+ startedAt: string;
177
+ ticks: number;
178
+ delivered: number;
179
+ held: number;
180
+ lastTickAt: string | null;
181
+ lastWhy: string | null;
182
+ stop: () => void;
183
+ };
184
+ const running = new Map<string, TailRecord>();
185
+
186
+ /** What `capabilities` reports: the tails THIS PROCESS is actually running, not the ones it could run. */
187
+ export function herdrTailState(): { agentId: string; running: true; startedAt: string; ticks: number; delivered: number; held: number; lastTickAt: string | null; lastWhy: string | null }[] {
188
+ return [...running.values()].map(({ stop: _stop, ...r }) => ({ ...r, running: true as const }));
189
+ }
190
+
191
+ export function stopHerdrTail(agentId: string): boolean {
192
+ const r = running.get(agentId);
193
+ if (!r) return false;
194
+ r.stop();
195
+ running.delete(agentId);
196
+ return true;
197
+ }
198
+ export function stopAllHerdrTails(): void {
199
+ for (const id of [...running.keys()]) stopHerdrTail(id);
200
+ }
201
+
202
+ /**
203
+ * Start the tail for `agentId` if this server's transport is herdr. IDEMPOTENT: a second bind of
204
+ * the same agent in the same process returns `running: true, started: false`.
205
+ *
206
+ * ⛔ NEVER OVERLAPS ITSELF: a tick still typing when the timer fires would interleave two pastes
207
+ * into one pane; the guard is one in-flight flag, because the next tick re-reads the cursor.
208
+ */
209
+ export function ensureHerdrTail(
210
+ agentId: string,
211
+ opts: { transport?: Transport; pollMs?: number; tail?: (id: string, ctx: TailContext) => Promise<TailOutcome> } = {},
212
+ ): { started: boolean; running: boolean; why: string } {
213
+ const t = opts.transport ?? activeTransport();
214
+ 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` };
215
+ if (running.has(agentId)) return { started: false, running: true, why: "already tailing this agent in this process" };
216
+ const ctx = newContext();
217
+ const run = opts.tail ?? ((id: string, c: TailContext) => tailOnce(id, { transport: t, ctx: c }));
218
+ const rec: TailRecord = { agentId, startedAt: new Date().toISOString(), ticks: 0, delivered: 0, held: 0, lastTickAt: null, lastWhy: null, stop: () => {} };
219
+ let inFlight = false;
220
+ const timer = setInterval(() => {
221
+ if (inFlight) return;
222
+ inFlight = true;
223
+ void run(agentId, ctx)
224
+ .then((o) => {
225
+ rec.ticks += 1;
226
+ rec.lastTickAt = new Date().toISOString();
227
+ rec.delivered += o.delivered.length;
228
+ rec.held += o.held.length;
229
+ rec.lastWhy = o.held.at(-1)?.why ?? o.idle ?? null;
230
+ })
231
+ .catch((e) => { rec.lastWhy = `tick threw: ${(e as Error).message}`; })
232
+ .finally(() => { inFlight = false; });
233
+ }, opts.pollMs ?? DEFAULT_POLL_MS);
234
+ timer.unref?.(); // optional-call: `unref` is absent on some host timer shims, and a server that cannot unref its tail must still start it
235
+ rec.stop = () => clearInterval(timer);
236
+ running.set(agentId, rec);
237
+ return { started: true, running: true, why: `tailing ${agentId}'s inbox and rooms every ${opts.pollMs ?? DEFAULT_POLL_MS}ms` };
238
+ }
239
+
240
+ /** The hook the server calls at EVERY identity binding — `join`, a gated first claim, or the env var. */
241
+ export function startTailOnBind(agentId: string, log: (line: string) => void = (l) => console.error(l)): void {
242
+ const r = ensureHerdrTail(agentId);
243
+ if (r.started) log(`[agent-coord-mcp] herdr tail: ${r.why}`);
244
+ }
@@ -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