agent-coord-mcp 0.26.33 → 0.26.36

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.
package/src/store.ts CHANGED
@@ -153,10 +153,86 @@ export function ensureDirs(): void {
153
153
  }
154
154
  }
155
155
 
156
- // Synchronous, deliberate. The result feeds the server's bearer→agent
157
- // reverse-lookup map; we want startup to fail loudly on a malformed file
158
- // rather than silently degrade to advisory mode. Returns null if the file
159
- // is absent (operator hasn't configured binding yet).
156
+ // ⟨q-4b9cdeab⟩ RELEASE A — THE READER ACCEPTS BOTH FORMS AND WRITES NEITHER.
157
+ //
158
+ // `tokens.json` holds every seat's bearer in the clear, so one readable file is the whole fleet's
159
+ // identity. The fix is that the file stores a HASH while the seat's config keeps the secret. Because
160
+ // 13 live HTTP seats depend on this file's shape, the migration is two-form first (David's ruling,
161
+ // 2026-09-23, docs/notes/Q_4B9CDEAB_HASHED_TOKENS_PLAN.md): this release READS both forms and writes
162
+ // NEITHER. No migration command, no hashed file written, no cutover. `coord-token` keeps minting
163
+ // plaintext.
164
+ //
165
+ // v1 (today): { "alice": "tk_<secret>" }
166
+ // v2 (hashed): { "alice": { "alg": "sha256", "hash": "<hex>", "createdAt": "…", "previous": {…} } }
167
+ //
168
+ // The two are told apart by the VALUE TYPE, so an untouched file keeps working with no version field.
169
+ //
170
+ // ⭐ ONE LOOKUP PATH FOR BOTH FORMS: a v1 entry is HASHED AT LOAD, so the in-process map is always
171
+ // hash → agentId and `agentForBearer` hashes whatever is presented. There is no second comparison to
172
+ // drift, and no branch where a plaintext file authenticates differently from a hashed one.
173
+ //
174
+ // ⛔ AN ENTRY THIS BUILD CANNOT EVALUATE REFUSES THE WHOLE FILE, BY NAME. An unknown `alg` is never
175
+ // ignored (which would silently drop a seat) and never treated as plaintext (which would compare a
176
+ // bearer against a hash and lock that seat out while looking fine). Same for a missing or malformed
177
+ // hash. The refusal names the AGENT and the ALG — never any part of a bearer, and never the hash.
178
+ //
179
+ // ⚠ `previous` (the rotation grace window) is NOT honoured in release A: it is written by nothing in
180
+ // this release, so accepting it would be accepting a shape no tool here produces. A bearer that
181
+ // matches only `previous` does not authenticate, and that is asserted rather than left implicit.
182
+ //
183
+ // Synchronous, deliberate: startup fails loudly on a malformed file rather than silently degrading
184
+ // to advisory mode. Returns null if the file is absent (binding not configured).
185
+ /** The hash algorithms this build can evaluate. An entry naming anything else refuses the file. */
186
+ export const TOKEN_HASH_ALGS = Object.freeze(["sha256"] as const);
187
+ const HEX64 = /^[0-9a-f]{64}$/;
188
+ /**
189
+ * ⛔ sha256 OF THE EMPTY STRING. v1 refused an empty token STRUCTURALLY (`value.length === 0`); the
190
+ * hashed form has no such shape, because this hex is as well-formed as any other. It is the exact
191
+ * artefact of an ordinary shell slip during the hand migration this release exists for —
192
+ * `printf '%s' "$TOK" | shasum -a 256` with `$TOK` unset or mistyped writes precisely this — and an
193
+ * entry holding it authenticates an `Authorization: Bearer ` header with nothing after it.
194
+ */
195
+ const EMPTY_BEARER_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
196
+
197
+ /** sha256 hex of a bearer. Unsalted on purpose: a bearer is 24 CSPRNG bytes, so there is no
198
+ * dictionary to run, and a per-entry salt would force a linear scan of every entry on every
199
+ * request. See §2 of the plan for the condition that would make that choice wrong. */
200
+ export function bearerHash(bearer: string): string {
201
+ return createHash("sha256").update(bearer, "utf8").digest("hex");
202
+ }
203
+
204
+ /** How many entries of each form the loaded map came from — what the census line reports. */
205
+ export type TokenMapCensus = { total: number; hashed: number; plaintext: number };
206
+ let tokenMapCensus: TokenMapCensus = { total: 0, hashed: 0, plaintext: 0 };
207
+ export function getTokenMapCensus(): TokenMapCensus {
208
+ return { ...tokenMapCensus };
209
+ }
210
+
211
+ function refuse(detail: string): never {
212
+ throw new Error(`[agent-coord-mcp] ${TOKENS_FILE}: ${detail}`);
213
+ }
214
+
215
+ /**
216
+ * Insert a hash → agentId claim, REFUSING a hash two agents both claim.
217
+ *
218
+ * ⛔ LAST-WINS WOULD RE-ATTRIBUTE A LIVE BEARER. Under v1 a collision meant two identical plaintext
219
+ * strings, visible to anyone reading the file. With one side hashed it is invisible on inspection —
220
+ * a property this release removed, so the reader has to put it back. The invariant this keeps is
221
+ * `map.size === census.total`: every entry claims its own key or the file is refused.
222
+ */
223
+ function claim(out: Map<string, string>, hash: string, agentId: string): void {
224
+ const held = out.get(hash);
225
+ if (held !== undefined) {
226
+ refuse(
227
+ `agents "${held}" and "${agentId}" both resolve to the SAME token hash. One bearer cannot ` +
228
+ `authenticate two identities: whichever entry loaded last would silently take the other's ` +
229
+ `traffic, and with one side hashed the clash is invisible in the file. Give each agent its own token.`,
230
+ );
231
+ }
232
+ out.set(hash, agentId);
233
+ }
234
+
235
+ /** Read the file into a hash → agentId map, accepting v1 and v2 entries. NEVER writes. */
160
236
  export function readTokenMapSync(): Map<string, string> | null {
161
237
  if (!existsSync(TOKENS_FILE)) return null;
162
238
  const raw = readFileSync(TOKENS_FILE, "utf8");
@@ -171,21 +247,89 @@ export function readTokenMapSync(): Map<string, string> | null {
171
247
  }
172
248
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
173
249
  throw new Error(
174
- `[agent-coord-mcp] ${TOKENS_FILE} must be a JSON object mapping agentId → token.`,
250
+ `[agent-coord-mcp] ${TOKENS_FILE} must be a JSON object mapping agentId → token or hashed entry.`,
175
251
  );
176
252
  }
177
253
  const out = new Map<string, string>();
178
- for (const [agentId, token] of Object.entries(parsed as Record<string, unknown>)) {
179
- if (typeof token !== "string" || token.length === 0) {
180
- throw new Error(
181
- `[agent-coord-mcp] ${TOKENS_FILE}: agent "${agentId}" has a non-string/empty token.`,
254
+ const census: TokenMapCensus = { total: 0, hashed: 0, plaintext: 0 };
255
+ for (const [agentId, value] of Object.entries(parsed as Record<string, unknown>)) {
256
+ census.total += 1;
257
+ if (typeof value === "string") {
258
+ // v1: the secret itself is on disk. Hash it here so the map has ONE shape.
259
+ if (value.length === 0) refuse(`agent "${agentId}" has a non-string/empty token.`);
260
+ claim(out, bearerHash(value), agentId);
261
+ census.plaintext += 1;
262
+ continue;
263
+ }
264
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
265
+ refuse(`agent "${agentId}" has a non-string/empty token.`);
266
+ }
267
+ const entry = value as { alg?: unknown; hash?: unknown };
268
+ const alg = entry.alg;
269
+ if (typeof alg !== "string" || !(TOKEN_HASH_ALGS as readonly string[]).includes(alg)) {
270
+ refuse(
271
+ `agent "${agentId}" uses hash alg ${JSON.stringify(alg ?? null)}, which this build cannot evaluate ` +
272
+ `(known: ${TOKEN_HASH_ALGS.join(", ")}). Refusing the whole file: ignoring the entry would drop that ` +
273
+ `seat silently, and treating it as a plaintext token would compare a bearer against a hash and lock ` +
274
+ `the seat out while every file on disk looked correct.`,
182
275
  );
183
276
  }
184
- out.set(token, agentId);
277
+ if (typeof entry.hash !== "string" || !HEX64.test(entry.hash)) {
278
+ refuse(`agent "${agentId}" has a ${alg} entry whose hash is not 64 lowercase hex characters.`);
279
+ }
280
+ if (entry.hash === EMPTY_BEARER_HASH) {
281
+ refuse(
282
+ `agent "${agentId}" has a ${alg} entry whose hash is sha256 of the EMPTY STRING. An empty ` +
283
+ `Authorization bearer would authenticate as that agent. The plaintext form refused an empty ` +
284
+ `token by its shape; this hex is well-formed, so it is refused by name instead. It is what ` +
285
+ `hashing an unset or mistyped shell variable produces — re-hash the real token.`,
286
+ );
287
+ }
288
+ claim(out, entry.hash, agentId);
289
+ census.hashed += 1;
185
290
  }
291
+ tokenMapCensus = census;
186
292
  return out;
187
293
  }
188
294
 
295
+ /** The agent a presented bearer belongs to, or undefined. The ONE place a bearer is compared. */
296
+ export function agentForBearer(bearer: string): string | undefined {
297
+ // ⚠ NO SEPARATE EMPTY-BEARER GUARD HERE, DELIBERATELY. An empty `Authorization: Bearer ` slices to
298
+ // "" and hashes to sha256(""), and the reader REFUSES any entry carrying that hash — so the map can
299
+ // never hold the only key an empty bearer could match. A guard here would therefore be unkillable
300
+ // by any test: I added one, the mutation matrix showed removing it changed nothing, and a conjunct
301
+ // no test can fire belongs in a comment rather than in the code. The enforcement point is the
302
+ // reader's refusal, and it is tested.
303
+ //
304
+ // ⛔ WHAT THAT DELETION COSTS, NAMED BECAUSE NOTHING ASSERTS IT: the guarantee is no longer local to
305
+ // this function. It holds only while `readTokenMapSync` is the map's ONLY PRODUCER — true today at
306
+ // all three assignments to `tokenMapCache` (the lazy first load, `reloadTokenMapSync`, and the
307
+ // refresh, which reads through `readTokenMapSync`). TWO changes void it, and BOTH are silent because
308
+ // the guard that would have caught them was deleted for being unfireable:
309
+ // 1. LOOSENING THE REFUSAL — if an entry carrying sha256("") stops being refused;
310
+ // 2. ⛔ A SECOND PRODUCER — anything that populates or mutates the map outside `readTokenMapSync`.
311
+ // This is the one release B actually SCHEDULES: `coord-token migrate` writes hashed entries, and
312
+ // a migrate path that seeds or patches the in-process map rather than re-reading the file
313
+ // reintroduces the empty-bearer key with nothing between it and the auth path.
314
+ // Either one is where the guard goes back — with a test that can fail. If you are here writing
315
+ // `coord-token migrate`, this paragraph is addressed to you.
316
+ return getTokenMap()?.get(bearerHash(bearer));
317
+ }
318
+
319
+ /** One line an operator can act on. Says nothing about any bearer, prefix or hash. */
320
+ export function tokenMapCensusLine(): string | null {
321
+ const map = getTokenMap();
322
+ if (!map) return null;
323
+ const c = getTokenMapCensus();
324
+ const tail =
325
+ c.total === 0
326
+ ? " — no entries: every bearer will be refused"
327
+ : c.plaintext > 0
328
+ ? ` — ${c.plaintext} still plaintext, run coord-token migrate`
329
+ : " — all hashed";
330
+ return `[agent-coord-mcp] token map: ${c.total} entries (${c.hashed} hashed, ${c.plaintext} plaintext)${tail}`;
331
+ }
332
+
189
333
  // In-process cache of the token map, shared by the HTTP identity-binding
190
334
  // layer (server.ts) and rotateAgentToken below. A module-level cache (rather
191
335
  // than each caller re-reading the file) is what lets a rename refresh the
@@ -623,6 +767,88 @@ export function archiveInboxFile(agentId: string): string {
623
767
  return path.join(ARCHIVE_INBOX_DIR, `${sanitize(agentId)}.jsonl`);
624
768
  }
625
769
 
770
+ /**
771
+ * What one archive pass did to a stream, in BOTH units a cursor can be measured in.
772
+ *
773
+ * ⛔ `removed` IS A LINE COUNT. `rebaseBytes` IS THE ONLY CORRECT WAY TO MOVE A BYTE OFFSET.
774
+ * A read cursor counts entries; a push cursor (`<agent>.push.json`) is the byte just past the
775
+ * last line typed into the pane. `adjustCursors` once subtracted `removed` from both — a line
776
+ * count from a byte offset — so every compaction left each push cursor far past the new EOF,
777
+ * and `advancePushCursor`'s monotonic `Math.max` meant nothing brought it back: the seat went
778
+ * deaf until the room organically regrew past the stale value (⟨q-f44f17a7⟩, 2026-09-24).
779
+ */
780
+ export type ArchiveResult = { kept: number; removed: number; archived: number; rebaseBytes: (offset: number) => number };
781
+
782
+ /**
783
+ * The byte offset in the rewritten file that corresponds to `offset` in the original one: the
784
+ * bytes of every KEPT line that ended at or before it. Exact, not a subtraction — compaction keeps
785
+ * fresh decisions from before its boundary, so removed and kept lines interleave. An offset inside
786
+ * a line maps to that line's start (it is re-offered, never skipped); one past the old EOF — a
787
+ * cursor already corrupted by the old rule — maps to the new EOF.
788
+ */
789
+ function byteRebase(keptOldEnd: number[], keptNewEnd: number[], oldSize: number): (offset: number) => number {
790
+ return (offset) => {
791
+ const x = Math.min(Math.max(0, offset), oldSize);
792
+ let lo = 0;
793
+ let hi = keptOldEnd.length; // first index whose old end is PAST x
794
+ while (lo < hi) {
795
+ const mid = (lo + hi) >> 1;
796
+ if (keptOldEnd[mid]! <= x) lo = mid + 1;
797
+ else hi = mid;
798
+ }
799
+ return lo === 0 ? 0 : keptNewEnd[lo - 1]!;
800
+ };
801
+ }
802
+
803
+ /**
804
+ * The pure half of an archive pass: which lines stay, which go, and the byte map between the old file
805
+ * and the rewritten one. No I/O, so the SAME rule is what `archiveJsonl` writes and what the
806
+ * `push-cursor-rebased-in-bytes` capability probe asks — not a copy of it.
807
+ */
808
+ export function planArchive<T>(raw: string, filter: (entry: T) => boolean): { keep: string[]; archive: string[] } & Omit<ArchiveResult, "archived"> {
809
+ let kept = 0;
810
+ let removed = 0;
811
+ const keep: string[] = [];
812
+ const archive: string[] = [];
813
+ // Where each kept line ENDED in the old file and ends in the new one.
814
+ const keptOldEnd: number[] = [];
815
+ const keptNewEnd: number[] = [];
816
+ let oldPos = 0;
817
+ let newPos = 0;
818
+ const segments = raw.split("\n");
819
+ for (let i = 0; i < segments.length; i++) {
820
+ const line = segments[i]!;
821
+ oldPos += Buffer.byteLength(line, "utf8") + (i < segments.length - 1 ? 1 : 0);
822
+ if (!line.trim()) continue;
823
+ let keepIt = false;
824
+ try {
825
+ keepIt = filter(JSON.parse(line) as T);
826
+ } catch {
827
+ keepIt = false; // malformed lines are archived verbatim rather than parsed
828
+ }
829
+ if (keepIt) {
830
+ keep.push(line);
831
+ kept++;
832
+ newPos += Buffer.byteLength(line, "utf8") + 1;
833
+ keptOldEnd.push(oldPos);
834
+ keptNewEnd.push(newPos);
835
+ } else {
836
+ archive.push(line);
837
+ removed++;
838
+ }
839
+ }
840
+ return { keep, archive, kept, removed, rebaseBytes: byteRebase(keptOldEnd, keptNewEnd, oldPos) };
841
+ }
842
+
843
+ /**
844
+ * Where a cursor lands after a rewrite, in ITS OWN unit: a read cursor counts entries (shifted down by
845
+ * the entries removed, clamped at 0), a push cursor is a byte offset (mapped exactly). The one place
846
+ * the unit is chosen — by the cursor's kind, never by a caller.
847
+ */
848
+ export function moveCursorOffset(kind: "read" | "push", offset: number, rw: Pick<ArchiveResult, "removed" | "rebaseBytes">): number {
849
+ return kind === "push" ? rw.rebaseBytes(offset) : Math.max(0, offset - rw.removed);
850
+ }
851
+
626
852
  // Like rewriteJsonl, but entries failing the filter are appended to
627
853
  // archiveFile instead of discarded. The archive append happens under the
628
854
  // source-file lock and BEFORE the rewrite, so a crash mid-operation can at
@@ -632,36 +858,16 @@ export async function archiveJsonl<T>(
632
858
  file: string,
633
859
  archiveFile: string,
634
860
  filter: (entry: T) => boolean
635
- ): Promise<{ kept: number; removed: number; archived: number }> {
636
- if (!existsSync(file)) return { kept: 0, removed: 0, archived: 0 };
861
+ ): Promise<ArchiveResult> {
862
+ if (!existsSync(file)) return { kept: 0, removed: 0, archived: 0, rebaseBytes: () => 0 };
637
863
  return withLock(file, async () => {
638
- const raw = await fs.readFile(file, "utf8");
639
- let kept = 0;
640
- let removed = 0;
641
- const keep: string[] = [];
642
- const archive: string[] = [];
643
- for (const line of raw.split("\n")) {
644
- if (!line.trim()) continue;
645
- try {
646
- const entry = JSON.parse(line) as T;
647
- if (filter(entry)) {
648
- keep.push(line);
649
- kept++;
650
- } else {
651
- archive.push(line);
652
- removed++;
653
- }
654
- } catch {
655
- archive.push(line);
656
- removed++;
657
- }
658
- }
864
+ const { keep, archive, kept, removed, rebaseBytes } = planArchive(await fs.readFile(file, "utf8"), filter);
659
865
  if (archive.length) {
660
866
  await fs.mkdir(path.dirname(archiveFile), { recursive: true });
661
867
  await fs.appendFile(archiveFile, archive.join("\n") + "\n", "utf8");
662
868
  }
663
869
  await fs.writeFile(file, keep.length ? keep.join("\n") + "\n" : "", "utf8");
664
- return { kept, removed, archived: archive.length };
870
+ return { kept, removed, archived: archive.length, rebaseBytes };
665
871
  });
666
872
  }
667
873
 
@@ -16,6 +16,7 @@ import {
16
16
  listCursorFiles,
17
17
  listInboxFiles,
18
18
  listReceiptFiles,
19
+ moveCursorOffset,
19
20
  normalizeRoom,
20
21
  pruneHistory,
21
22
  readJson,
@@ -23,6 +24,7 @@ import {
23
24
  rewriteJsonl,
24
25
  roomFile,
25
26
  updateJson,
27
+ type ArchiveResult,
26
28
  type RoomRegistry,
27
29
  } from "../store.js";
28
30
  import {
@@ -60,48 +62,59 @@ function keepEntry(
60
62
  return isDecision(e) ? e.ts > decisionCutoff : e.ts > cutoff;
61
63
  }
62
64
 
63
- // Shift every agent's cursor offsets down by the number of entries removed
64
- // ahead of them (append-only + oldest-first removal ⇒ plain subtraction,
65
- // clamped at 0). Shared by prune and live compaction — without this, an
66
- // offset past the new file length makes read_messages return [] forever.
67
- export async function adjustCursors(removals: {
68
- roomRemovedByChan?: Record<string, number>;
69
- statusRemoved?: number;
70
- perAgentInboxRemoved?: Record<string, number>;
65
+ /**
66
+ * Carry every agent's cursors across a rewrite of the streams they index. Shared by prune and
67
+ * live compaction — without this, an offset past the new file length makes read_messages return
68
+ * [] forever.
69
+ *
70
+ * ⛔ EACH CURSOR IS MOVED IN ITS OWN UNIT (⟨q-f44f17a7⟩). A READ cursor (`<agent>.json`) counts
71
+ * entries: it is shifted down by the entries removed (clamped at 0; at-least-once if kept
72
+ * decisions sat behind it). A PUSH cursor (`<agent>.push.json`) is a BYTE offset: it is mapped
73
+ * through the archive pass's `rebaseBytes`, exactly. This took three visits to one function:
74
+ * ⟨q-40449919⟩ fixed the push FILENAME and left the unit wrong, and ⟨q-c36a078f⟩ fixed the same
75
+ * confusion in push-cursor seeding. So the caller cannot pass a bare count any more — it passes the
76
+ * whole `ArchiveResult`, which carries both units, and the choice between them is made HERE by the
77
+ * cursor's own kind rather than trusted to every call site.
78
+ */
79
+ export type StreamRewrite = Pick<ArchiveResult, "removed" | "rebaseBytes">;
80
+ export async function adjustCursors(rewrites: {
81
+ roomByChan?: Record<string, StreamRewrite>;
82
+ status?: StreamRewrite;
83
+ inboxByAgent?: Record<string, StreamRewrite>;
71
84
  }): Promise<string[]> {
72
- const roomRemovedByChan = removals.roomRemovedByChan ?? {};
73
- const statusRemoved = removals.statusRemoved ?? 0;
74
- const perAgentInboxRemoved = removals.perAgentInboxRemoved ?? {};
85
+ const roomByChan = rewrites.roomByChan ?? {};
86
+ const inboxByAgent = rewrites.inboxByAgent ?? {};
75
87
  const cursorsAdjusted: string[] = [];
76
88
  for (const cname of await listCursorFiles()) {
77
89
  // q-40449919: this was `cname.replace(/\.json$/, "")`, which strips only
78
90
  // the outer extension. A push cursor's `<agent>.push.json` then keyed
79
- // `perAgentInboxRemoved` lookups below on `<agent>.push` — a key that
91
+ // `inboxByAgent` lookups below on `<agent>.push` — a key that
80
92
  // never matches — so a push cursor's inboxOffset was silently never
81
- // decremented after a prune shrank the inbox, drifting it toward a
82
- // false past-EOF the next time doctor ran.
83
- const { id } = agentIdFromCursorFilename(cname);
93
+ // adjusted after a prune shrank the inbox.
94
+ const { id, kind } = agentIdFromCursorFilename(cname);
95
+ const move = (offset: number, rw: StreamRewrite) => moveCursorOffset(kind, offset, rw);
84
96
  const cursorPath = path.join(CURSOR_DIR, cname);
85
97
  let touched = false;
86
98
  await updateJson<Cursor>(cursorPath, {}, (current) => {
87
- for (const [chan, removed] of Object.entries(roomRemovedByChan)) {
99
+ for (const [chan, rw] of Object.entries(roomByChan)) {
100
+ if (rw.removed <= 0) continue;
88
101
  if (chan === DEFAULT_ROOM) {
89
102
  if (current.roomOffset !== undefined) {
90
- current.roomOffset = Math.max(0, current.roomOffset - removed);
103
+ current.roomOffset = move(current.roomOffset, rw);
91
104
  touched = true;
92
105
  }
93
106
  } else if (current.roomOffsets?.[chan] !== undefined) {
94
- current.roomOffsets[chan] = Math.max(0, current.roomOffsets[chan] - removed);
107
+ current.roomOffsets[chan] = move(current.roomOffsets[chan], rw);
95
108
  touched = true;
96
109
  }
97
110
  }
98
- if (current.statusOffset !== undefined && statusRemoved > 0) {
99
- current.statusOffset = Math.max(0, current.statusOffset - statusRemoved);
111
+ if (current.statusOffset !== undefined && rewrites.status && rewrites.status.removed > 0) {
112
+ current.statusOffset = move(current.statusOffset, rewrites.status);
100
113
  touched = true;
101
114
  }
102
- const myInboxRemoved = perAgentInboxRemoved[id] ?? 0;
103
- if (current.inboxOffset !== undefined && myInboxRemoved > 0) {
104
- current.inboxOffset = Math.max(0, current.inboxOffset - myInboxRemoved);
115
+ const myInbox = inboxByAgent[id];
116
+ if (current.inboxOffset !== undefined && myInbox && myInbox.removed > 0) {
117
+ current.inboxOffset = move(current.inboxOffset, myInbox);
105
118
  touched = true;
106
119
  }
107
120
  return current;
@@ -232,26 +245,26 @@ export async function pruneTool(args: {
232
245
  // Per-channel removed counts so we can shift the matching offset in each
233
246
  // cursor (default channel → roomOffset, others → roomOffsets[chan]).
234
247
  // Aged-out entries are archived to archive/rooms/<chan>.jsonl, never deleted.
235
- const roomRemovedByChan: Record<string, number> = {};
248
+ const roomByChan: Record<string, StreamRewrite> = {};
236
249
  let roomRemovedTotal = 0;
237
250
  if (targets.has("rooms")) {
238
251
  for (const chan of channels) {
239
252
  const r = await archiveJsonl<Message>(roomFile(chan), archiveRoomFile(chan), keep);
240
253
  if (r.removed > 0) {
241
- roomRemovedByChan[chan] = r.removed;
254
+ roomByChan[chan] = r;
242
255
  roomRemovedTotal += r.removed;
243
256
  }
244
257
  }
245
258
  }
246
259
  const statusResult = targets.has("status")
247
260
  ? await archiveJsonl<StatusEntry>(STATUS_FILE, ARCHIVE_STATUS_FILE, (e) => e.ts > cutoff)
248
- : { kept: 0, removed: 0, archived: 0 };
261
+ : { kept: 0, removed: 0, archived: 0, rebaseBytes: () => 0 };
249
262
 
250
263
  let inboxRemoved = 0;
251
264
  const deletedOrphans: string[] = [];
252
- // perAgentInboxRemoved: how many entries were stripped from each *kept* agent's
253
- // inbox, so we can shift only that agent's inboxOffset cursor below.
254
- const perAgentInboxRemoved: Record<string, number> = {};
265
+ // inboxByAgent: what was stripped from each *kept* agent's inbox (in both
266
+ // units), so we can shift only that agent's inboxOffset cursors below.
267
+ const inboxByAgent: Record<string, StreamRewrite> = {};
255
268
  if (targets.has("inbox")) {
256
269
  for (const fname of await listInboxFiles()) {
257
270
  const id = fname.replace(/\.jsonl$/, "");
@@ -266,14 +279,14 @@ export async function pruneTool(args: {
266
279
  }
267
280
  const r = await archiveJsonl<Message>(filePath, archiveInboxFile(id), (e) => e.ts > cutoff);
268
281
  inboxRemoved += r.removed;
269
- perAgentInboxRemoved[id] = r.removed;
282
+ inboxByAgent[id] = r;
270
283
  }
271
284
  }
272
285
 
273
286
  const cursorsAdjusted = await adjustCursors({
274
- roomRemovedByChan,
275
- statusRemoved: statusResult.removed,
276
- perAgentInboxRemoved,
287
+ roomByChan,
288
+ status: statusResult,
289
+ inboxByAgent,
277
290
  });
278
291
 
279
292
  // Membership sweep: drop orphans (not in registry) and stale members (no
@@ -41,7 +41,7 @@ import { readFileSync, statSync } from "node:fs";
41
41
  import { inboxFile, roomFile, getRooms, transportFile } from "../store.js";
42
42
  import { activeTransport, HERDR, targetOf, type Transport, type TransportMarker } from "../transports/index.js";
43
43
  import { renderForPane, advancePushCursor, readPushCursorFor, type Message } from "./herdr-delivery.js";
44
- import { newMessagesIn } from "./jsonl-offsets.js";
44
+ import { newMessagesIn, idEndingAt, endOfId } from "./jsonl-offsets.js";
45
45
 
46
46
  export const DEFAULT_POLL_MS = 1000;
47
47
  /** Marker and room membership are re-read this often. A room joined mid-session is picked up within this many ticks. */
@@ -61,6 +61,12 @@ export type TailContext = {
61
61
  rooms: string[];
62
62
  /** In-process high-water mark per source: never re-type below this, even if the persistent cursor could not be written. */
63
63
  hw: Map<string, number>;
64
+ /**
65
+ * The id of the message each `hw` mark was set just past (null: set with no message, e.g. an EOF
66
+ * seed of a file whose last line has no id). A byte mark means something only while THAT message
67
+ * still ends there — see the rewrite check in tailOnce.
68
+ */
69
+ hwId: Map<string, string | null>;
64
70
  /** Last seen size per source: a source that has not grown costs one stat. */
65
71
  sizes: Map<string, number>;
66
72
  /**
@@ -73,10 +79,17 @@ export type TailContext = {
73
79
  seedEof: boolean;
74
80
  /** Sources already seeded — a room discovered on a later refresh is seeded on ITS first sight, not skipped. */
75
81
  seeded: Set<string>;
82
+ /** Sources whose persisted cursor is past EOF right now — logged once on entry, not on every tick. */
83
+ pastEof: Set<string>;
76
84
  };
77
- export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map(), seedEof, seeded: new Set() });
85
+ export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), hwId: new Map(), sizes: new Map(), seedEof, seeded: new Set(), pastEof: new Set() });
78
86
 
79
87
  const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
88
+ /** Set the in-process mark just past `msg`, remembering which message it follows. */
89
+ const mark = (ctx: TailContext, key: string, end: number, msg: Record<string, unknown>) => {
90
+ ctx.hw.set(key, end);
91
+ ctx.hwId.set(key, msg.id === undefined || msg.id === null ? null : String(msg.id));
92
+ };
80
93
  const sizeOf = (file: string): number => {
81
94
  try { return statSync(file).size; } catch { return 0; }
82
95
  };
@@ -169,10 +182,34 @@ export async function tailOnce(
169
182
  if (ctx.seedEof && !ctx.seeded.has(key)) {
170
183
  ctx.seeded.add(key);
171
184
  ctx.hw.set(key, size);
185
+ ctx.hwId.set(key, idEndingAt(src.file, size));
172
186
  log(`[herdr-tail] seeded ${key} at EOF (${size} bytes) for ${agentId} — first sight this process, nothing predating the tail will be typed`);
173
187
  continue;
174
188
  }
189
+ // ⛔ A BYTE MARK IS ONLY VALID WHILE THE SAME MESSAGE STILL ENDS THERE (⟨q-f44f17a7⟩). Any sender's
190
+ // server compacts a room on send, and every byte after the cut then means something else. A mark
191
+ // left PAST the new EOF made the seat deaf until the room regrew past it (the 40-minute blind
192
+ // stretch of 2026-09-24); a mark left INSIDE the new file resumes mid-stream and skips whatever
193
+ // now sits below it. So the mark is checked against the message it was set after: moved to where
194
+ // that message now ends, or — if it was archived — dropped, and the PERSISTED cursor, which the
195
+ // compactor rebased exactly (adjustCursors → rebaseBytes), is the position again.
196
+ const held = ctx.hw.get(key);
197
+ if (held !== undefined && idEndingAt(src.file, held) !== (ctx.hwId.get(key) ?? null)) {
198
+ const id = ctx.hwId.get(key);
199
+ const moved = id ? endOfId(src.file, id) : null;
200
+ log(`[herdr-tail] ${key} for ${agentId} was rewritten beneath the in-process mark (byte ${held}, after ${id ?? "no id"}) — ${moved !== null ? `moved to byte ${moved}, where that message now ends` : "that message is gone; resuming from the rebased push cursor"}`);
201
+ if (moved !== null) ctx.hw.set(key, moved);
202
+ else { ctx.hw.delete(key); ctx.hwId.delete(key); }
203
+ }
175
204
  const from = await offsetOf(agentId, src, ctx);
205
+ // …and if the PERSISTED cursor is past EOF too, the compactor has not rebased it yet (it rewrites
206
+ // the file first, then the cursors) or a server without this fix compacted. `size <= from` below
207
+ // skips it either way; this only makes the state VISIBLE, once per entry — on 2026-09-24 the only
208
+ // measurements of this defect came from a seat reading the room file by hand.
209
+ if (from > size) {
210
+ if (!ctx.pastEof.has(key)) log(`[herdr-tail] ${key} push cursor for ${agentId} is past EOF (${from} > ${size} bytes) — nothing can be typed from there until it is rebased or the file regrows past it (q-f44f17a7)`);
211
+ ctx.pastEof.add(key);
212
+ } else ctx.pastEof.delete(key);
176
213
  // ⭐ NEGATIVE CONTROL FOR THE CLASS THIS ROW IS ABOUT: nothing is logged here when
177
214
  // `size === from` — a source with nothing owed must stay silent, or every idle room on
178
215
  // every tick becomes a line and the log that is supposed to make a real stall visible
@@ -180,7 +217,7 @@ export async function tailOnce(
180
217
  if (size <= from) continue;
181
218
 
182
219
  for (const { msg, end } of newMessagesIn(src.file, from)) {
183
- if (msg.from === agentId) { ctx.hw.set(key, end); continue; }
220
+ if (msg.from === agentId) { mark(ctx, key, end, msg); continue; }
184
221
  const where = src.kind === "dm" ? ({ kind: "dm" } as const) : ({ kind: "room", chan: src.chan! } as const);
185
222
  const rendered = await renderForPane(msg as unknown as Message, agentId, where);
186
223
  let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
@@ -207,13 +244,13 @@ export async function tailOnce(
207
244
  // re-typing a message that may already be sitting in the box is the paste loop in another coat.
208
245
  // A throw lands here too: nothing says whether a key went out before it.
209
246
  if (!r.delivered || r.verified !== true) {
210
- ctx.hw.set(key, end);
247
+ mark(ctx, key, end, msg);
211
248
  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)` });
212
249
  break;
213
250
  }
214
251
  // It landed and was verified. Record that in-process FIRST, so a cursor write that fails
215
252
  // cannot turn one delivery into a paste loop, then persist exactly past this line.
216
- ctx.hw.set(key, end);
253
+ mark(ctx, key, end, msg);
217
254
  const persisted = await advancePushCursor(agentId, where, end);
218
255
  out.delivered.push({ kind: src.kind, chan: src.chan, id: String(msg.id ?? "") });
219
256
  if (!persisted) {
@@ -27,3 +27,26 @@ export function newMessagesIn(file: string, from: number): OffsetMessage[] {
27
27
  }
28
28
  return out;
29
29
  }
30
+
31
+ /**
32
+ * The id of the message whose line ends EXACTLY at byte `end`, or null (no such line, past EOF, not
33
+ * JSON, no id). What makes a byte offset checkable: a position is only still valid while the SAME
34
+ * message ends there — a compaction rewrites the file and every byte after the cut then means
35
+ * something else (⟨q-f44f17a7⟩).
36
+ */
37
+ export function idEndingAt(file: string, end: number): string | null {
38
+ let buf: Buffer;
39
+ try { buf = readFileSync(file); } catch { return null; }
40
+ if (end <= 0 || end > buf.length || buf[end - 1] !== 0x0a) return null;
41
+ const start = end >= 2 ? buf.lastIndexOf(0x0a, end - 2) + 1 : 0;
42
+ try {
43
+ const id = (JSON.parse(buf.subarray(start, end - 1).toString("utf8")) as { id?: unknown }).id;
44
+ return id === undefined || id === null ? null : String(id);
45
+ } catch { return null; }
46
+ }
47
+
48
+ /** The byte just past the line of the message with this id, or null if the file no longer holds it. */
49
+ export function endOfId(file: string, id: string): number | null {
50
+ for (const { msg, end } of newMessagesIn(file, 0)) if (String(msg.id ?? "") === id) return end;
51
+ return null;
52
+ }
@@ -549,7 +549,7 @@ async function maybeCompactRoom(chan: string): Promise<void> {
549
549
  archiveRoomFile(chan),
550
550
  (e) => e.ts >= boundaryTs || (isDecision(e) && e.ts > decisionCutoff)
551
551
  );
552
- if (r.removed > 0) await adjustCursors({ roomRemovedByChan: { [chan]: r.removed } });
552
+ if (r.removed > 0) await adjustCursors({ roomByChan: { [chan]: r } });
553
553
  }
554
554
 
555
555
  async function maybeCompactStatus(): Promise<void> {
@@ -562,7 +562,7 @@ async function maybeCompactStatus(): Promise<void> {
562
562
  ARCHIVE_STATUS_FILE,
563
563
  (e) => e.ts >= boundaryTs
564
564
  );
565
- if (r.removed > 0) await adjustCursors({ statusRemoved: r.removed });
565
+ if (r.removed > 0) await adjustCursors({ status: r });
566
566
  }
567
567
 
568
568
  // ---------- read_messages ----------