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/dist/capabilities.js +45 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/server.js +18 -4
- package/dist/server.js.map +1 -1
- package/dist/store.js +216 -35
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +28 -31
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/herdr-tail.js +41 -5
- package/dist/tools/herdr-tail.js.map +1 -1
- package/dist/tools/jsonl-offsets.js +32 -0
- package/dist/tools/jsonl-offsets.js.map +1 -1
- package/dist/tools/messaging.js +2 -2
- package/dist/tools/messaging.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities.ts +45 -1
- package/src/server.ts +16 -3
- package/src/store.ts +240 -34
- package/src/tools/admin.ts +46 -33
- package/src/tools/herdr-tail.ts +42 -5
- package/src/tools/jsonl-offsets.ts +23 -0
- package/src/tools/messaging.ts +2 -2
package/src/store.ts
CHANGED
|
@@ -153,10 +153,86 @@ export function ensureDirs(): void {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// is
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
|
|
package/src/tools/admin.ts
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
73
|
-
const
|
|
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
|
-
// `
|
|
91
|
+
// `inboxByAgent` lookups below on `<agent>.push` — a key that
|
|
80
92
|
// never matches — so a push cursor's inboxOffset was silently never
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
const
|
|
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,
|
|
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 =
|
|
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] =
|
|
107
|
+
current.roomOffsets[chan] = move(current.roomOffsets[chan], rw);
|
|
95
108
|
touched = true;
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
|
-
if (current.statusOffset !== undefined &&
|
|
99
|
-
current.statusOffset =
|
|
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
|
|
103
|
-
if (current.inboxOffset !== undefined &&
|
|
104
|
-
current.inboxOffset =
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
const
|
|
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
|
-
|
|
282
|
+
inboxByAgent[id] = r;
|
|
270
283
|
}
|
|
271
284
|
}
|
|
272
285
|
|
|
273
286
|
const cursorsAdjusted = await adjustCursors({
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
287
|
+
roomByChan,
|
|
288
|
+
status: statusResult,
|
|
289
|
+
inboxByAgent,
|
|
277
290
|
});
|
|
278
291
|
|
|
279
292
|
// Membership sweep: drop orphans (not in registry) and stale members (no
|
package/src/tools/herdr-tail.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/tools/messaging.ts
CHANGED
|
@@ -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({
|
|
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({
|
|
565
|
+
if (r.removed > 0) await adjustCursors({ status: r });
|
|
566
566
|
}
|
|
567
567
|
|
|
568
568
|
// ---------- read_messages ----------
|