multi-agent-collaboration-mcp 0.13.0 → 0.16.0

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/db.js CHANGED
@@ -22,8 +22,6 @@ function resolveDbPath() {
22
22
  }
23
23
  return join(homedir(), ".agent-chat-mcp", "chat.db");
24
24
  }
25
- /** SQLite's default maximum string/blob byte length (SQLITE_MAX_LENGTH). */
26
- export const SQLITE_MAX_LENGTH = 1_000_000_000;
27
25
  /** Application safety cap. SQLite's ~1 GB theoretical ceiling is not a safe
28
26
  * API limit: JSON parsing, validation, binding, WAL, and FTS can hold several
29
27
  * copies of one body at once. */
@@ -43,6 +41,17 @@ export const MAX_CLIENT_MESSAGE_ID_CHARS = 200;
43
41
  * generates against it before allocating, rather than discovering the overflow
44
42
  * as a store assertion after the fact. */
45
43
  export const MAX_AGENT_ID_CHARS = 200;
44
+ /**
45
+ * Longest human BASE name, derived from MAX_AGENT_ID_CHARS rather than chosen:
46
+ * the canonical id is `human-` + base + `-` + ordinal, and the widest ordinal
47
+ * is Number.MAX_SAFE_INTEGER at 16 digits.
48
+ *
49
+ * 6 ("human-") + 177 (base) + 1 ("-") + 16 (ordinal) = 200
50
+ *
51
+ * The complete candidate is still validated before insertion; this bound only
52
+ * guarantees no VALID base can be rejected later by the id cap.
53
+ */
54
+ export const MAX_HUMAN_BASE_CHARS = 177;
46
55
  /** Above this body length the store's json well-formedness re-validation is
47
56
  * skipped: parsing a ~GB body into memory to walk it would defeat the
48
57
  * memory-bounded read design, and such a caller owns validation (the MCP
@@ -139,8 +148,9 @@ const THREAD_ENVELOPE = JSON.stringify({
139
148
  }).length -
140
149
  2 -
141
150
  2;
142
- /** Serialized-size allowance below which shrinkToFit is guaranteed to fit any
143
- * legal row as a stub (fixed fields ~430 worst case); budgets floor here. */
151
+ /** Serialized array allowance within which boundByBytes can fit any legal
152
+ * first row as a stub. It includes the array's two brackets, so boundByBytes
153
+ * passes STUB_ALLOWANCE - 2 as the row budget (fixed fields ~430 worst case). */
144
154
  const STUB_ALLOWANCE = 500;
145
155
  /** Smallest budget that can safely carry catch_up's fixed fields plus one
146
156
  * shrunk message stub. The MCP wrapper subtracts its own routing/wait
@@ -148,62 +158,73 @@ const STUB_ALLOWANCE = 500;
148
158
  * remains it must reject the call rather than advance past an undeliverable
149
159
  * page. */
150
160
  export const MIN_CATCH_UP_RESULT_BUDGET = Math.max(CATCH_UP_ENVELOPE, PRIORITY_CATCH_UP_ENVELOPE) + STUB_ALLOWANCE;
151
- /**
152
- * A persona-authored operation was fenced out: the runtime that issued it no
153
- * longer holds the persona, because a later valid resume took it over. Terminal
154
- * by contract -- retrying cannot succeed, and the caller must create or resume a
155
- * persona before acting again. A distinct class (not a bare Error) so the MCP
156
- * layer can render it as `persona_lost` without string-matching a message.
157
- */
158
161
  export class PersonaLostError extends Error {
159
162
  agentId;
160
- expectedEpoch;
161
- currentEpoch;
162
- constructor(agentId, expectedEpoch, currentEpoch) {
163
- super(currentEpoch === null
164
- ? `persona "${agentId}" no longer exists; create or resume a persona before acting`
165
- : `persona "${agentId}" was taken over by a later runtime ` +
166
- `(your epoch ${expectedEpoch}, current ${currentEpoch}); ` +
167
- `this runtime's authority is gone and retrying cannot restore it`);
163
+ reason;
164
+ /** The agents row is gone entirely. */
165
+ missing;
166
+ /** The row exists and is terminally retired. */
167
+ retired;
168
+ constructor(agentId, reason) {
169
+ super(
170
+ // Describes ONLY the lost identity and the operation that failed. It
171
+ // deliberately gives no process-recovery advice: whether the caller
172
+ // should restart depends on session state this layer cannot see, and an
173
+ // unconditional "call identify_persona" would contradict the fact that a
174
+ // process still holding this dead binding is exactly the case where
175
+ // identify_persona must fail.
176
+ reason === "missing"
177
+ ? `persona "${agentId}" no longer exists, so this operation cannot proceed`
178
+ : reason === "retired"
179
+ ? `persona "${agentId}" was terminally retired and can never act ` +
180
+ `again; its history stands, but it cannot post, advance a read ` +
181
+ `marker, claim, or join`
182
+ : `persona "${agentId}" exists and is live, but it is not the ` +
183
+ `identity this operation was issued under; that authority is gone ` +
184
+ `and retrying this operation cannot restore it`);
168
185
  this.name = "PersonaLostError";
169
186
  this.agentId = agentId;
170
- this.expectedEpoch = expectedEpoch;
171
- this.currentEpoch = currentEpoch;
187
+ this.reason = reason;
188
+ // Derived here rather than passed, so the two can never both be true: a
189
+ // row cannot be simultaneously absent and retired, and a live-row binding
190
+ // mismatch is neither.
191
+ this.missing = reason === "missing";
192
+ this.retired = reason === "retired";
172
193
  }
173
194
  }
174
195
  /**
175
- * A CORRECT resume word presented with a different brand/model/version.
196
+ * This process generated a connection nonce that some row already carries,
197
+ * BEFORE it had bound anything.
176
198
  *
177
- * This is not a rejected credential. The word proves the caller is the
178
- * persona's legitimate owner; what it reports is that the thing sitting behind
179
- * the seat CHANGED. brand/model/version are immutable by construction, so a
180
- * runtime whose actual model no longer matches them is a different participant
181
- * asking to wear an old name, and letting it through would keep posting under a
182
- * model identifier that is false to every peer reading the room -- the one
183
- * thing the tuple exists to prevent.
199
+ * Thrown, not silently resolved, because the only correct response lives in the
200
+ * process that owns the nonce: generate another one and identify again. The
201
+ * store must never attach to a row merely because its nonce matches, which is
202
+ * exactly what a "reuse the matching row" fallback would do -- and that row
203
+ * belongs to a different process incarnation. Reachable only while unbound; a
204
+ * bound process compares against the binding it recorded.
205
+ */
206
+ export class ConnectionNonceCollisionError extends Error {
207
+ connectionId;
208
+ constructor(connectionId) {
209
+ super(`connection nonce ${connectionId} is already recorded against another ` +
210
+ `persona row; regenerate it and identify again`);
211
+ this.name = "ConnectionNonceCollisionError";
212
+ this.connectionId = connectionId;
213
+ }
214
+ }
215
+ /**
216
+ * Nickname allocation exhausted its candidates inside the identify transaction.
184
217
  *
185
- * Distinct from a wrong word (a rejected credential, whose remedy is to find
186
- * the right word) and from PersonaLostError (a takeover, whose remedy is to
187
- * resume again). The remedy here is a NEW persona.
218
+ * The six-hex token is only 24 bits, so a collision is a normal event and the
219
+ * caller retries with a fresh candidate. Running out means the id space for
220
+ * this tuple is genuinely saturated (or the candidate supplier is broken), and
221
+ * the transaction rolls back rather than committing a half-transition.
188
222
  */
189
- export class ModelTupleMismatchError extends Error {
190
- agentId;
191
- stored;
192
- offered;
193
- /** Rooms the persona is currently present in, so the caller can be told which
194
- * conversations to notify. It has no binding to look them up with itself. */
195
- rooms;
196
- constructor(agentId, stored, offered, rooms) {
197
- super(`persona "${agentId}" is ${stored.brand}/${stored.model}/${stored.version}, ` +
198
- `but this runtime is ${offered.brand}/${offered.model}/${offered.version}. ` +
199
- `The resume word is correct, so this is a MODEL CHANGE, not a bad ` +
200
- `credential: brand/model/version are immutable and a persona cannot be ` +
201
- `carried across them. Create a new persona instead.`);
202
- this.name = "ModelTupleMismatchError";
203
- this.agentId = agentId;
204
- this.stored = stored;
205
- this.offered = offered;
206
- this.rooms = rooms;
223
+ export class NicknameExhaustedError extends Error {
224
+ constructor(attempts) {
225
+ super(`could not allocate a free persona nickname after ${attempts} attempts; ` +
226
+ `no identity was created and any prior binding is unchanged`);
227
+ this.name = "NicknameExhaustedError";
207
228
  }
208
229
  }
209
230
  /** Serialized-size budget for a metadata listing's row ARRAY, leaving room for
@@ -322,6 +343,33 @@ function fitRows(rows, budget) {
322
343
  }
323
344
  return { rows: kept, sizeTrimmed };
324
345
  }
346
+ /**
347
+ * Fit an unread-room summary to a byte budget: drop whole rows with fitRows,
348
+ * then, because fitRows always keeps one, halve a lone survivor's `name` until
349
+ * the MEASURED serialized size fits. A fixed code-unit cut under-counts JSON
350
+ * escaping, so a control-heavy name slipped past it; room_id stays the stable
351
+ * key, and the caller keeps its own reason-for-truncation flag separate.
352
+ */
353
+ function fitRoomSummary(rooms, budget) {
354
+ const fitted = fitRows(rooms, budget);
355
+ const kept = fitted.rows;
356
+ if (kept.length === 1 && JSON.stringify(kept).length > budget) {
357
+ let entry = { ...kept[0] };
358
+ while (JSON.stringify([entry]).length > budget && entry.name.length > 0) {
359
+ entry = {
360
+ ...entry,
361
+ name: safeCut(entry.name, Math.floor(entry.name.length / 2)),
362
+ };
363
+ }
364
+ kept[0] = entry;
365
+ return { rooms: kept, sizeTrimmed: true };
366
+ }
367
+ return { rooms: kept, sizeTrimmed: fitted.sizeTrimmed };
368
+ }
369
+ /** Bounded list of a retired identity's former rooms carried in the transition
370
+ * response. The cap exists because a persona can be present in an unbounded
371
+ * number of rooms and the response must stay inside the client output cap. */
372
+ export const MAX_PREVIOUS_ROOM_NAMES = 200;
325
373
  /** Full body length in CODEPOINTS, the unit the reported `length` field uses
326
374
  * everywhere. Prefers the fetched-row codepoint count; for a synthetic row
327
375
  * built in code (no body_cp), the number of codepoints in the body in hand. */
@@ -357,11 +405,11 @@ function messageCols(bodyCap) {
357
405
  length(g.body) AS body_cp,
358
406
  g.mentions, g.reply_to_seq,
359
407
  datetime(g.created_at, 'localtime') AS created_local,
360
- CAST(strftime('%s', g.created_at) AS INTEGER) AS created_unix,
408
+ unixepoch(g.created_at) AS created_unix,
361
409
  g.supersedes_seq,
362
- (SELECT s.seq FROM messages s
363
- WHERE s.room_id = g.room_id AND s.supersedes_seq = g.seq
364
- ORDER BY s.seq DESC LIMIT 1) AS superseded_by,
410
+ (SELECT MAX(s.seq)
411
+ FROM messages s INDEXED BY idx_messages_supersedes
412
+ WHERE s.room_id = g.room_id AND s.supersedes_seq = g.seq) AS superseded_by,
365
413
  p.agent_id AS reply_from, substr(p.body, 1, 101) AS reply_preview`;
366
414
  }
367
415
  // No join to agents: a message envelope carries the author id and nothing else
@@ -370,6 +418,11 @@ function messageCols(bodyCap) {
370
418
  // never have held when writing it.
371
419
  const MESSAGE_FROM = `messages g
372
420
  LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq`;
421
+ /** Deleted rooms cannot be rejoined; point callers to valid recovery paths. */
422
+ function deletedRoomMessage(roomId) {
423
+ return (`room ${roomId} no longer exists (it was deleted); it cannot be rejoined -- ` +
424
+ "use list_rooms to see what remains, or create_room to make a new one");
425
+ }
373
426
  /**
374
427
  * SQL predicate for "directed at me": an explicit mention, OR a reply to a
375
428
  * message I authored. Binds the agent id to TWO `?` placeholders in order
@@ -386,6 +439,41 @@ export function directedAt(alias) {
386
439
  return `(EXISTS (SELECT 1 FROM json_each(${alias}.mentions) WHERE value = ?)
387
440
  OR IFNULL(${alias}.reply_to_agent = ?, 0))`;
388
441
  }
442
+ function chmodBestEffort(path, mode) {
443
+ try {
444
+ chmodSync(path, mode);
445
+ }
446
+ catch { }
447
+ }
448
+ function enableWalWithRetry(db) {
449
+ // Switching a brand-new rollback-journal file to WAL needs an exclusive
450
+ // lock, and SQLite can return SQLITE_BUSY here WITHOUT consulting the
451
+ // busy handler (better-sqlite3's default 5s timeout does not cover this
452
+ // path), so two fresh processes racing to convert the same file
453
+ // intermittently crashed on startup (reproduced ~1 in 24 synchronized
454
+ // opens). This is a STARTUP RACE, not a compatibility path: retry with a
455
+ // short synchronous backoff and the loser finds the file already in WAL
456
+ // and succeeds immediately. A no-op on already-WAL files, i.e. every
457
+ // startup after the first.
458
+ for (let attempt = 1;; attempt++) {
459
+ try {
460
+ db.pragma("journal_mode = WAL");
461
+ break;
462
+ }
463
+ catch (e) {
464
+ // Prefix match: better-sqlite3 surfaces EXTENDED result codes (e.g.
465
+ // SQLITE_BUSY_RECOVERY when another connection is mid-WAL-recovery,
466
+ // plausible in exactly this conversion race), and all of them mean
467
+ // the same thing here: someone else holds the file, try again.
468
+ const code = e.code ?? "";
469
+ if (attempt >= 20 || !code.startsWith("SQLITE_BUSY")) {
470
+ throw e;
471
+ }
472
+ // Synchronous sleep (constructor context); ~4.75s worst-case total.
473
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
474
+ }
475
+ }
476
+ }
389
477
  export class ChatStore {
390
478
  path;
391
479
  db;
@@ -402,56 +490,22 @@ export class ChatStore {
402
490
  // already existed; chmod only that. Best-effort: permissions are a
403
491
  // hardening layer, not a startup gate (and a no-op concept on Windows).
404
492
  const created = mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
405
- try {
406
- if (created)
407
- chmodSync(created, 0o700);
408
- for (const p of [path, `${path}-wal`, `${path}-shm`]) {
409
- if (existsSync(p))
410
- chmodSync(p, 0o600);
411
- }
493
+ if (created)
494
+ chmodBestEffort(created, 0o700);
495
+ for (const p of [path, `${path}-wal`, `${path}-shm`]) {
496
+ if (existsSync(p))
497
+ chmodBestEffort(p, 0o600);
412
498
  }
413
- catch { }
414
499
  }
415
500
  this.db = new Database(path);
416
501
  try {
417
502
  if (path !== ":memory:") {
418
- try {
419
- chmodSync(path, 0o600);
420
- }
421
- catch { }
422
- }
423
- // Switching a brand-new rollback-journal file to WAL needs an exclusive
424
- // lock, and SQLite can return SQLITE_BUSY here WITHOUT consulting the
425
- // busy handler (better-sqlite3's default 5s timeout does not cover this
426
- // path), so two fresh processes racing to convert the same file
427
- // intermittently crashed on startup (reproduced ~1 in 24 synchronized
428
- // opens). This is a STARTUP RACE, not a compatibility path: retry with a
429
- // short synchronous backoff and the loser finds the file already in WAL
430
- // and succeeds immediately. A no-op on already-WAL files, i.e. every
431
- // startup after the first.
432
- for (let attempt = 1;; attempt++) {
433
- try {
434
- this.db.pragma("journal_mode = WAL");
435
- break;
436
- }
437
- catch (e) {
438
- // Prefix match: better-sqlite3 surfaces EXTENDED result codes (e.g.
439
- // SQLITE_BUSY_RECOVERY when another connection is mid-WAL-recovery,
440
- // plausible in exactly this conversion race), and all of them mean
441
- // the same thing here: someone else holds the file, try again.
442
- const code = e.code ?? "";
443
- if (attempt >= 20 || !code.startsWith("SQLITE_BUSY")) {
444
- throw e;
445
- }
446
- // Synchronous sleep (constructor context); ~4.75s worst-case total.
447
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
448
- }
503
+ chmodBestEffort(path, 0o600);
449
504
  }
505
+ enableWalWithRetry(this.db);
450
506
  this.db.pragma("busy_timeout = 5000");
451
507
  this.db.pragma("foreign_keys = ON");
452
- // One process initializes the schema at a time. Without an IMMEDIATE
453
- // transaction, concurrent MCP startups could all find the FTS index
454
- // missing and each repeat a full-corpus rebuild.
508
+ // Initialize the current schema atomically and serialize concurrent starts.
455
509
  this.db.transaction(() => this.initializeSchema()).immediate();
456
510
  }
457
511
  catch (error) {
@@ -480,40 +534,133 @@ export class ChatStore {
480
534
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
481
535
  );
482
536
 
483
- -- One row per PERSONA. An LLM persona carries an immutable
484
- -- brand/model/version tuple and a server-generated resume word; a human
485
- -- web participant carries neither. The CHECK makes those the ONLY two
486
- -- shapes any writer can create -- including direct ones (the web server,
487
- -- sqlite3, a test) that never pass through this file's JS guards -- so a
488
- -- half-persona (resumable without metadata, or metadata with no resume
489
- -- path) cannot exist in the database.
537
+ -- One row per PERSONA, in exactly three legal shapes:
538
+ --
539
+ -- human human_base/human_ordinal set, no tuple, no
540
+ -- connection, never retired
541
+ -- bound LLM complete tuple, a connection nonce, not retired
542
+ -- retired LLM complete tuple, no connection, retired_at set
543
+ --
544
+ -- The CHECK is what makes those the ONLY shapes ANY writer can create,
545
+ -- including the direct ones (web/server.mjs holds its own handle, tests
546
+ -- open the file, sqlite3 exists) that never pass through this file's JS
547
+ -- guards. JS still owns trim() semantics, UUID format, and readable
548
+ -- messages; SQL owns the shape, because JS cannot reach the other
549
+ -- writers.
550
+ --
551
+ -- "Bound" deliberately does not say LIVE. A hard-killed process leaves a
552
+ -- non-null connection_id behind and nothing may read that as liveness or
553
+ -- as possession: no caller can submit a connection value, and a first
554
+ -- bind that collides regenerates instead of adopting the row.
490
555
  CREATE TABLE IF NOT EXISTS agents (
491
- id TEXT PRIMARY KEY,
556
+ -- NOT NULL is NOT redundant here. In an ordinary rowid table SQLite
557
+ -- deliberately departs from the standard: a PRIMARY KEY on a non-
558
+ -- INTEGER column does not imply NOT NULL, so 'id TEXT PRIMARY KEY'
559
+ -- alone accepts NULL, and repeatedly, because NULLs compare distinct in
560
+ -- the implicit unique index. Two nameless participants is the result.
561
+ id TEXT PRIMARY KEY NOT NULL,
492
562
  is_human INTEGER NOT NULL DEFAULT 0,
493
563
  brand TEXT,
494
564
  model TEXT,
495
565
  version TEXT,
496
- resume_word TEXT,
497
- -- Monotonic, never reused. Creation binds runtime 1; every bindingless
498
- -- attach increments. A runtime captures this value when it binds, and
499
- -- an operation carrying a captured value below the current one is from
500
- -- a superseded tenure. Monotonicity is what a reusable process nonce
501
- -- lacked: a nonce can be handed back to the same runtime after it lost
502
- -- and regained the persona, making a stale in-flight write look current.
503
- runtime_epoch INTEGER NOT NULL DEFAULT 1,
566
+ -- The MCP stdio server PROCESS INCARNATION holding this identity.
567
+ -- Internal association only: it is never returned by a tool, never
568
+ -- accepted as input, and never joined into a message or listing.
569
+ connection_id TEXT,
570
+ human_base TEXT,
571
+ human_ordinal INTEGER,
572
+ -- Terminal for LLM rows. Never cleared, so a nickname is never
573
+ -- reactivated: A -> B -> A is three distinct identities.
574
+ retired_at TEXT,
504
575
  description TEXT,
505
576
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
577
+ CHECK (is_human IN (0, 1)),
578
+ -- NUL FIRST, because it is what makes every other text check honest.
579
+ -- SQLite's length(), substr(), and GLOB all stop at the first NUL, so
580
+ -- without this an id of 'ab<NUL>cd' measures 2, passes the bounds, and
581
+ -- passes the character grammar -- while every listing that reads it
582
+ -- back silently loses the tail. Measured, not assumed: length() of a
583
+ -- NUL-bearing string returns the prefix count, and the || operator
584
+ -- PRESERVES the NUL, which is why this one check also covers
585
+ -- human_base through the id-equality rule below.
586
+ CHECK (instr(id, char(0)) = 0),
587
+ -- Lower bound as well as upper: '' is a legal TEXT primary key in
588
+ -- SQLite, and an empty nickname would be an addressable participant
589
+ -- nobody can mention, reply to, or tell apart from a missing value.
590
+ CHECK (length(id) BETWEEN 1 AND ${MAX_AGENT_ID_CHARS}),
506
591
  CHECK (
507
592
  (is_human = 1
508
- AND brand IS NULL AND model IS NULL
509
- AND version IS NULL AND resume_word IS NULL)
593
+ AND brand IS NULL AND model IS NULL AND version IS NULL
594
+ AND connection_id IS NULL AND retired_at IS NULL
595
+ AND human_base IS NOT NULL AND human_ordinal IS NOT NULL
596
+ AND length(human_base) BETWEEN 1 AND ${MAX_HUMAN_BASE_CHARS}
597
+ -- ASCII letters, digits, underscore, dot, dash only. GLOB is
598
+ -- case-sensitive, so this rejects whitespace, ordinary control
599
+ -- characters, and anything non-ASCII. It does NOT reject an
600
+ -- embedded NUL: GLOB stops scanning there, so a NUL-bearing base
601
+ -- passes this test. The global instr(id, ...) check above is what
602
+ -- covers that case, via the id-equality rule below.
603
+ AND human_base NOT GLOB '*[^A-Za-z0-9_.-]*'
604
+ AND substr(human_base, 1, 1) GLOB '[A-Za-z0-9_]'
605
+ AND lower(substr(human_base, 1, 6)) <> 'human-'
606
+ -- typeof, not just bounds: 1.5 satisfies both bounds, and
607
+ -- CAST(1.5 AS TEXT) is '1.5', so the id-equality rule would
608
+ -- happily accept 'human-alex-1.5' as a canonical identity.
609
+ AND typeof(human_ordinal) = 'integer'
610
+ AND human_ordinal > 0
611
+ AND human_ordinal <= ${Number.MAX_SAFE_INTEGER}
612
+ -- Implies the reserved lowercase prefix: id is BUILT from it.
613
+ AND id = 'human-' || human_base || '-' || CAST(human_ordinal AS TEXT))
510
614
  OR
511
615
  (is_human = 0
512
- AND brand IS NOT NULL AND model IS NOT NULL
513
- AND version IS NOT NULL AND resume_word IS NOT NULL)
616
+ AND human_base IS NULL AND human_ordinal IS NULL
617
+ AND brand IS NOT NULL AND model IS NOT NULL AND version IS NOT NULL
618
+ -- length() is NUL-terminated in SQLite, so the instr() guards do
619
+ -- the real work: a lone NUL measures 0 and fails the lower bound,
620
+ -- but a NUL in the MIDDLE measures only the prefix before it and
621
+ -- would otherwise pass.
622
+ AND length(brand) BETWEEN 1 AND 100
623
+ AND length(model) BETWEEN 1 AND 100
624
+ AND length(version) BETWEEN 1 AND 100
625
+ AND instr(brand, char(0)) = 0
626
+ AND instr(model, char(0)) = 0
627
+ AND instr(version, char(0)) = 0
628
+ -- Case-insensitive, so 'Human-' cannot squat the namespace.
629
+ AND lower(substr(id, 1, 6)) <> 'human-'
630
+ AND ((retired_at IS NULL AND connection_id IS NOT NULL)
631
+ OR (retired_at IS NOT NULL AND connection_id IS NULL)))
514
632
  )
515
633
  );
516
634
 
635
+ -- Durable idempotency for human nickname allocation, keyed by an
636
+ -- operation UUID the BROWSER generates before its first request. It
637
+ -- turns "the response was lost" and "localStorage.setItem threw" from
638
+ -- duplicate-identity events into replays.
639
+ --
640
+ -- Deliberately NO foreign key on room_id: deleting a room must not erase
641
+ -- the record needed to answer a replay. A replay for a deleted room
642
+ -- returns the identity that was committed and reports the room gone,
643
+ -- which is recoverable; losing the row would allocate a second ordinal
644
+ -- for one user action, which is not.
645
+ --
646
+ -- This is idempotency, not ownership. It authenticates nothing.
647
+ CREATE TABLE IF NOT EXISTS human_allocations (
648
+ -- NOT NULL for the same SQLite reason as agents.id, and here it is the
649
+ -- whole point of the table: NULL keys compare distinct, so without it
650
+ -- one browser could insert unlimited NULL-keyed rows and the
651
+ -- idempotency this table exists to provide would silently not exist.
652
+ operation_id TEXT PRIMARY KEY NOT NULL,
653
+ room_id INTEGER NOT NULL,
654
+ human_base TEXT NOT NULL,
655
+ result_agent_id TEXT NOT NULL REFERENCES agents(id),
656
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
657
+ -- typeof for the same reason as human_ordinal: affinity would let 1.5
658
+ -- through, and this value is compared against a rooms.id integer when
659
+ -- a replay decides whether the requested room still exists.
660
+ CHECK (typeof(room_id) = 'integer'
661
+ AND room_id > 0 AND room_id <= ${Number.MAX_SAFE_INTEGER})
662
+ );
663
+
517
664
  -- role is ROOM-LOCAL and nullable: one persona can be reviewer in one
518
665
  -- room and writer in another, and "no role" is a real state distinct from
519
666
  -- an empty string. It is deliberately NOT on agents, where a single value
@@ -604,6 +751,35 @@ export class ChatStore {
604
751
  WHEN instr(NEW.description, char(0)) > 0 BEGIN
605
752
  SELECT RAISE(ABORT, 'agent description contains a NUL character (U+0000)');
606
753
  END;
754
+
755
+ -- An allocation record must AGREE with the identity it records. The
756
+ -- foreign key only proves result_agent_id names some agent, so without
757
+ -- this a row could record base 'sam' against human-alex-1, or point at an
758
+ -- LLM row outright -- and a replay would then hand the browser back an
759
+ -- identity that is not the one the base asked for. A CHECK cannot express
760
+ -- this because it cannot read another table, hence a trigger.
761
+ CREATE TRIGGER IF NOT EXISTS human_allocations_require_matching_human
762
+ BEFORE INSERT ON human_allocations
763
+ WHEN NOT EXISTS (
764
+ SELECT 1 FROM agents a
765
+ WHERE a.id = NEW.result_agent_id
766
+ AND a.is_human = 1
767
+ AND a.human_base = NEW.human_base
768
+ ) BEGIN
769
+ SELECT RAISE(ABORT, 'human_allocations.result_agent_id must name a human agent whose human_base equals the recorded base');
770
+ END;
771
+
772
+ -- Append-only. Denying UPDATE and DELETE outright is smaller than re-validating either,
773
+ -- and there is no legitimate reason to rewrite a committed allocation:
774
+ -- its whole purpose is to be the durable answer a replay returns.
775
+ CREATE TRIGGER IF NOT EXISTS human_allocations_append_only
776
+ BEFORE UPDATE ON human_allocations BEGIN
777
+ SELECT RAISE(ABORT, 'human_allocations is append-only: a committed allocation record cannot be modified');
778
+ END;
779
+ CREATE TRIGGER IF NOT EXISTS human_allocations_no_delete
780
+ BEFORE DELETE ON human_allocations BEGIN
781
+ SELECT RAISE(ABORT, 'human_allocations is append-only: a committed allocation record cannot be deleted');
782
+ END;
607
783
  `);
608
784
  this.db.exec(`
609
785
  -- No explicit (room_id, seq) index: UNIQUE(room_id, seq) already provides
@@ -629,6 +805,19 @@ export class ChatStore {
629
805
  CREATE INDEX IF NOT EXISTS idx_memberships_agent_present
630
806
  ON memberships(agent_id, left_at, room_id);
631
807
 
808
+ -- One process incarnation holds at most one current LLM nickname. This
809
+ -- is the DATABASE's statement of that rule, so a direct writer cannot
810
+ -- point two live identities at one connection. Partial: retired and
811
+ -- human rows carry NULL and cost no index entry.
812
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_connection
813
+ ON agents(connection_id) WHERE connection_id IS NOT NULL;
814
+
815
+ -- What actually prevents two concurrent viewers from allocating the same
816
+ -- human-alex-1: SQLite serializes the writes, and this index rejects the
817
+ -- loser's duplicate ordinal instead of letting both commit.
818
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_human_identity
819
+ ON agents(human_base, human_ordinal) WHERE is_human = 1;
820
+
632
821
  -- Advisory single-winner work claims with TTL. Purely advisory: nothing
633
822
  -- fences the claimed resource itself; expiry frees claims from crashed
634
823
  -- holders.
@@ -653,17 +842,16 @@ export class ChatStore {
653
842
  -- memberships), so last_seen is the weaker signal of the two: recent
654
843
  -- runtime activity. "watching" stays the strong one.
655
844
  --
656
- -- Keyed (room_id, agent_id) with the OWNING epoch as data, not as part of
657
- -- the key: one persona has one runtime, so it has at most one wait per
658
- -- room, and a takeover must REPLACE the loser's row rather than sit
659
- -- beside it (two rows would report two watchers for one persona). The
660
- -- epoch column is what makes the loser's later cleanup safe: its DELETE
661
- -- is guarded on the epoch it captured, so it cannot remove the winner's
662
- -- row after an upsert has already overwritten it.
845
+ -- Keyed (room_id, agent_id) and nothing else. One persona belongs to one
846
+ -- connection and an agent_id names one tenure for the life of the
847
+ -- database, so a persona has at most one wait row per room and two rows
848
+ -- would report two watchers for one persona. A late cleanup from a
849
+ -- superseded identity cannot reach a successor's row because the
850
+ -- successor is a DIFFERENT agent_id, and retirement deletes the retired
851
+ -- persona's leases outright in the same transaction.
663
852
  CREATE TABLE IF NOT EXISTS wait_leases (
664
853
  room_id INTEGER NOT NULL REFERENCES rooms(id),
665
854
  agent_id TEXT NOT NULL REFERENCES agents(id),
666
- epoch INTEGER NOT NULL,
667
855
  started_at TEXT NOT NULL DEFAULT (datetime('now')),
668
856
  expires_at TEXT NOT NULL,
669
857
  PRIMARY KEY (room_id, agent_id)
@@ -696,51 +884,43 @@ export class ChatStore {
696
884
  VALUES ('delete', old.id, old.body);
697
885
  END;
698
886
  `);
699
- // Rebuild decision by INDEX CONSISTENCY, not table existence: a process
700
- // dying between the CREATE above and the rebuild below leaves a database
701
- // where every later start sees the table and skips the rebuild forever,
702
- // making everything written before the crash permanently invisible to
703
- // search. The empty/nonempty mismatch repairs that window in O(1), with no
704
- // corpus scan. The index row count MUST come from the messages_fts_docsize
705
- // shadow table: with external content, COUNT(*) on the virtual table itself
706
- // reads the content table and always matches. BOTH counts are read in ONE
707
- // statement (a single consistent snapshot): two separate SELECTs could
708
- // straddle a concurrent insert -- messages counted pre-insert, fts counted
709
- // post-trigger -- making an already-inconsistent {2,1} file read as {2,2}
710
- // and skip the rebuild it actually needed.
711
- const { hasMessages, hasFtsRows } = this.db
712
- .prepare(`SELECT EXISTS(SELECT 1 FROM messages LIMIT 1) AS hasMessages,
713
- EXISTS(SELECT 1 FROM messages_fts_docsize LIMIT 1) AS hasFtsRows`)
714
- .get();
715
- if (hasMessages !== hasFtsRows) {
716
- this.db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
717
- }
718
887
  }
719
888
  // --- rooms -------------------------------------------------------------
720
889
  /**
721
- * Create a room, epoch-fenced to the calling persona.
890
+ * Create a room, fenced to a live calling persona.
722
891
  *
723
892
  * Room administration stays UNAUTHENTICATED and global: any current persona
724
893
  * can create or delete any room, and nothing verifies who they are. What the
725
894
  * fence removes is authority from a runtime the system positively knows is
726
895
  * superseded. Leaving the highest-blast-radius operations as the only
727
- * unfenced writes made the epoch model exempt exactly what it should protect
896
+ * unfenced writes made the fence exempt exactly what it should protect
728
897
  * most: a fenced-out runtime could not post a message but could delete the
729
898
  * room the message was in.
730
899
  *
731
- * Rooms must precede JOINS, not bindings. create_persona/resume_persona is
732
- * one call that every participating runtime makes anyway, and
733
- * bind -> create_room -> join_room satisfies the bootstrap workflow.
900
+ * Rooms must precede JOINS, not identification. identify_persona is one call
901
+ * that every participating runtime makes anyway, and
902
+ * identify_persona -> create_room -> join_room satisfies the bootstrap
903
+ * workflow.
734
904
  */
735
- createRoom(name, description, pinned, agentId, epoch) {
905
+ createRoom(name, description, pinned, agentId) {
736
906
  assertStorable(name, "room name");
737
907
  assertMaxLen(name, "room name", 200);
908
+ // Room names are exact identifiers, so reject edge whitespace, do not trim.
909
+ if (name.length === 0 || name !== name.trim()) {
910
+ throw new Error("room name must be non-empty with no leading or trailing whitespace");
911
+ }
912
+ // Numeric references resolve as ids first, so an all-digit name can retarget
913
+ // reads and destructive operations to a different room.
914
+ if (/^\d+$/.test(name)) {
915
+ throw new Error("room names cannot be all digits (ambiguous with room ids); " +
916
+ "pick a descriptive kebab-case topic name");
917
+ }
738
918
  assertStorable(description, "room description");
739
919
  assertMaxLen(description, "room description", 2000);
740
920
  assertStorable(pinned, "room pinned intro");
741
921
  assertMaxLen(pinned, "room pinned intro", 10_000);
742
922
  const tx = this.db.transaction(() => {
743
- this.requireEpoch(agentId, epoch);
923
+ this.requireLive(agentId);
744
924
  return this.db
745
925
  .prepare(`INSERT INTO rooms (name, description, pinned) VALUES (?, ?, ?)
746
926
  RETURNING *`)
@@ -748,18 +928,21 @@ export class ChatStore {
748
928
  });
749
929
  return tx.immediate();
750
930
  }
751
- setPinned(roomId, agentId, epoch, pinned) {
931
+ setPinned(roomId, agentId, pinned) {
752
932
  assertStorable(pinned, "room pinned intro");
753
933
  assertMaxLen(pinned, "room pinned intro", 10_000);
754
934
  const tx = this.db.transaction(() => {
755
- this.requireEpoch(agentId, epoch);
935
+ this.requireLive(agentId);
936
+ // Writing the pinned intro is room participation.
937
+ this.requirePresent(roomId, agentId);
756
938
  const info = this.db
757
939
  .prepare("UPDATE rooms SET pinned = ? WHERE id = ?")
758
940
  .run(pinned, roomId);
759
- // 0 rows = the room vanished under us; report it instead of a false
760
- // success the caller would trust.
941
+ // A present membership has a foreign key to this row in the same
942
+ // transaction, so zero changes means the invariant is broken.
761
943
  if (info.changes === 0) {
762
- throw new Error(`room ${roomId} no longer exists (deleted); rejoin with join_room`);
944
+ throw new Error(`internal invariant violated: room ${roomId} has a live membership ` +
945
+ "but no room row; the database may be corrupt");
763
946
  }
764
947
  });
765
948
  tx.immediate();
@@ -767,17 +950,14 @@ export class ChatStore {
767
950
  getRoom(roomId) {
768
951
  return this.db.prepare("SELECT * FROM rooms WHERE id = ?").get(roomId);
769
952
  }
770
- /** Throw a clean, recoverable error when the room no longer exists. Called
771
- * INSIDE write transactions whose room reference was resolved earlier, so
772
- * a cross-process delete_room in the window yields this message instead of
773
- * a raw FK constraint failure or a false no-op success. */
953
+ /** Recheck room existence inside transactions where membership is not the
954
+ * gate, so a concurrent deletion returns a useful error. */
774
955
  requireRoom(roomId) {
775
956
  const row = this.db
776
957
  .prepare("SELECT 1 FROM rooms WHERE id = ?")
777
958
  .get(roomId);
778
- if (!row) {
779
- throw new Error(`room ${roomId} no longer exists (deleted); rejoin with join_room`);
780
- }
959
+ if (!row)
960
+ throw new Error(deletedRoomMessage(roomId));
781
961
  }
782
962
  /** Exact name lookup (never interprets the value as an id). */
783
963
  getRoomByName(name) {
@@ -790,10 +970,10 @@ export class ChatStore {
790
970
  */
791
971
  currentTime() {
792
972
  const r = this.db
793
- .prepare(`SELECT CAST(strftime('%s','now') AS INTEGER) AS unix,
973
+ .prepare(`SELECT unixepoch('now') AS unix,
794
974
  datetime('now','localtime') AS at,
795
- CAST(strftime('%s', datetime('now','localtime')) AS INTEGER)
796
- - CAST(strftime('%s','now') AS INTEGER) AS offset_seconds`)
975
+ unixepoch(datetime('now','localtime'))
976
+ - unixepoch('now') AS offset_seconds`)
797
977
  .get();
798
978
  // ISO 8601 local time with explicit offset, e.g. 2026-07-08T03:35:13-04:00.
799
979
  // offset_seconds is the local wall-clock read as UTC minus true UTC, i.e.
@@ -830,6 +1010,8 @@ export class ChatStore {
830
1010
  // a further page without a tail COUNT.
831
1011
  const { rows, total } = this.db
832
1012
  .transaction(() => {
1013
+ // Activity means the timestamp on the highest-seq message, not the
1014
+ // greatest wall-clock value. Clocks can move backward between posts.
833
1015
  const rows = this.db
834
1016
  .prepare(`SELECT r.id, r.name,
835
1017
  substr(r.description, 1, ${PREVIEW}) AS description,
@@ -839,7 +1021,8 @@ export class ChatStore {
839
1021
  r.created_at,
840
1022
  (SELECT COUNT(*) FROM memberships m WHERE m.room_id = r.id AND m.left_at IS NULL) AS members,
841
1023
  (SELECT COUNT(*) FROM messages g WHERE g.room_id = r.id) AS messages,
842
- (SELECT MAX(created_at) FROM messages g WHERE g.room_id = r.id) AS last_activity
1024
+ (SELECT created_at FROM messages g
1025
+ WHERE g.room_id = r.id ORDER BY g.seq DESC LIMIT 1) AS last_activity
843
1026
  FROM rooms r WHERE r.id > ? ORDER BY r.id LIMIT ?`)
844
1027
  .all(Math.max(0, Math.floor(afterId)), lim + 1);
845
1028
  const { c: total } = this.db
@@ -879,13 +1062,14 @@ export class ChatStore {
879
1062
  .get(roomId);
880
1063
  return c;
881
1064
  }
882
- /** How many rooms an identity is currently present in (for wait_for_messages
883
- * to refuse a doomed all-rooms watch when the agent is in none). */
884
- presentRoomCount(agentId) {
885
- const { c } = this.db
886
- .prepare("SELECT COUNT(*) AS c FROM memberships WHERE agent_id = ? AND left_at IS NULL")
1065
+ /** Total and present room memberships for watcher arm-time diagnostics. */
1066
+ roomMembershipCounts(agentId) {
1067
+ return this.db
1068
+ .prepare(`SELECT COUNT(*) AS total,
1069
+ COALESCE(SUM(CASE WHEN left_at IS NULL THEN 1 ELSE 0 END), 0)
1070
+ AS present
1071
+ FROM memberships WHERE agent_id = ?`)
887
1072
  .get(agentId);
888
- return c;
889
1073
  }
890
1074
  /** Names of the rooms this persona is present in. Bounded: this feeds an
891
1075
  * error message, and a persona in hundreds of rooms must not turn one
@@ -918,154 +1102,323 @@ export class ChatStore {
918
1102
  }
919
1103
  // --- personas -----------------------------------------------------------
920
1104
  /**
921
- * Verify that `epoch` is still the persona's current runtime epoch, throwing
922
- * PersonaLostError if not.
1105
+ * Verify that this identity still EXISTS and is not retired, throwing
1106
+ * PersonaLostError if either fails.
1107
+ *
1108
+ * This is the complete stale-operation fence. An agent_id is allocated once
1109
+ * and never deleted, reinserted, revived, or rebound, so it names exactly one
1110
+ * tenure forever; an operation captured under a superseded identity therefore
1111
+ * carries an id whose row is now retired, and that is what this catches. No
1112
+ * ordinal is needed to tell tenures apart because an id never has two.
923
1113
  *
924
1114
  * MUST be called from INSIDE the transaction that performs the guarded write.
925
- * Checking first and writing afterwards is not equivalent: a takeover
1115
+ * Checking first and writing afterwards is not equivalent: a retirement
926
1116
  * committing in the gap leaves the check passing and the write landing under
927
1117
  * an authority the caller no longer holds, which is the entire failure this
928
1118
  * exists to prevent. Every caller below is inside a tx.immediate().
929
1119
  */
930
- requireEpoch(agentId, epoch) {
1120
+ requireLive(agentId) {
931
1121
  const row = this.db
932
- .prepare("SELECT runtime_epoch FROM agents WHERE id = ?")
1122
+ .prepare("SELECT retired_at FROM agents WHERE id = ?")
933
1123
  .get(agentId);
934
1124
  if (!row)
935
- throw new PersonaLostError(agentId, epoch, null);
936
- if (row.runtime_epoch !== epoch) {
937
- throw new PersonaLostError(agentId, epoch, row.runtime_epoch);
938
- }
1125
+ throw new PersonaLostError(agentId, "missing");
1126
+ if (row.retired_at !== null)
1127
+ throw new PersonaLostError(agentId, "retired");
939
1128
  }
940
1129
  /**
941
- * Verify that this persona is PRESENT in the room, throwing if it has left.
942
- *
943
- * Like requireEpoch, this MUST run inside the transaction that performs the
944
- * guarded write. Checking first and writing afterwards leaves a leave_room
945
- * committing in the gap, and the write then lands from a persona every peer
946
- * has already been told is absent.
947
- *
948
- * Participation is what this guards: authoring, advancing a read marker,
949
- * changing a role, taking a new claim, and opening a wait. It deliberately
950
- * does NOT guard non-advancing history reads or releasing a claim you already
951
- * hold -- both are useful while absent and neither claims presence.
952
- *
953
- * The soft-left membership row survives, so the remedy is always the same and
954
- * the error says it: rejoin. join_room keeps the read marker and the role, so
955
- * recovery costs one call and loses nothing.
1130
+ * Transactional gate for present-only operations. It returns the cursor from
1131
+ * the same membership read. On failure, a room lookup distinguishes a
1132
+ * cascaded deletion from never-joined and soft-left membership states.
1133
+ * It must run inside the transaction that performs the guarded write.
1134
+ * Non-advancing reads and claim release intentionally bypass this gate.
956
1135
  */
957
1136
  requirePresent(roomId, agentId) {
958
1137
  const row = this.db
959
- .prepare("SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
1138
+ .prepare(`SELECT last_read_seq, left_at FROM memberships
1139
+ WHERE room_id = ? AND agent_id = ?`)
960
1140
  .get(roomId, agentId);
961
- if (!row) {
962
- throw new Error(`you are not a member of room ${roomId}; join_room it first`);
1141
+ if (row !== undefined && row.left_at === null) {
1142
+ return { last_read_seq: row.last_read_seq };
963
1143
  }
964
- if (row.left_at !== null) {
965
- throw new Error(`you have LEFT room ${roomId}, so you cannot participate in it: ` +
966
- `peers see you as absent. Call join_room to rejoin -- your read ` +
967
- `position and room-local role are preserved.`);
1144
+ const room = this.db
1145
+ .prepare("SELECT name FROM rooms WHERE id = ?")
1146
+ .get(roomId);
1147
+ if (!room)
1148
+ throw new Error(deletedRoomMessage(roomId));
1149
+ const where = `room "${room.name}" (${roomId})`;
1150
+ if (row === undefined) {
1151
+ throw new Error(`you have never joined ${where}; join_room it first`);
968
1152
  }
969
- }
970
- /** The persona's current epoch, or null if the row is gone. For
971
- * NON-advancing reads, which disclose loss without failing. */
972
- currentEpoch(agentId) {
1153
+ throw new Error(`you have LEFT ${where}, so you cannot participate in it: ` +
1154
+ `peers see you as absent. Call join_room to rejoin -- your read ` +
1155
+ `position and room-local role are preserved.`);
1156
+ }
1157
+ /** Why this binding is unusable, or null when it is live. For NON-advancing
1158
+ * reads, which DISCLOSE loss instead of failing, and for arm-time checks
1159
+ * that need the reason rather than an exception. */
1160
+ personaLoss(expected) {
1161
+ this.assertConnectionId(expected.connectionId);
973
1162
  const row = this.db
974
- .prepare("SELECT runtime_epoch FROM agents WHERE id = ?")
975
- .get(agentId);
976
- return row ? row.runtime_epoch : null;
1163
+ .prepare("SELECT is_human, connection_id, retired_at FROM agents WHERE id = ?")
1164
+ .get(expected.agentId);
1165
+ if (!row)
1166
+ return "missing";
1167
+ if (row.retired_at !== null)
1168
+ return "retired";
1169
+ return row.is_human !== 0 || row.connection_id !== expected.connectionId
1170
+ ? "binding_mismatch"
1171
+ : null;
977
1172
  }
978
1173
  getPersona(id) {
979
1174
  return this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
980
1175
  }
981
1176
  /**
982
- * Claim a generated persona id atomically. Returns false if the id is taken,
983
- * so a caller drawing candidate ids cannot collapse two personas onto one row
984
- * (and with it one read marker, one membership set, and one claim owner).
1177
+ * A connection nonce must be a UUID.
1178
+ *
1179
+ * The database only knows connection_id as opaque text, and the specification
1180
+ * assigns UUID format checking to this layer. Without it "" or "conn-A" bind
1181
+ * happily, and the column stops meaning "a value this server generated with
1182
+ * randomUUID()" -- which is the only claim it makes. Lowercase hex only,
1183
+ * because the sole legitimate producer is randomUUID().
985
1184
  */
986
- tryCreatePersona(p) {
1185
+ assertConnectionId(value) {
1186
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) {
1187
+ throw new Error("connection id must be a lowercase UUID generated by this server");
1188
+ }
1189
+ }
1190
+ /** Validate the parts of an LLM identity this file is responsible for. The
1191
+ * table CHECK covers the same ground for writers that never come through
1192
+ * here; this exists to fail with a message a caller can act on. */
1193
+ assertLlmIdentity(p) {
987
1194
  assertStorable(p.id, "persona id");
988
1195
  assertMaxLen(p.id, "persona id", MAX_AGENT_ID_CHARS);
989
- assertStorable(p.brand, "brand");
990
- assertMaxLen(p.brand, "brand", 100);
991
- assertStorable(p.model, "model");
992
- assertMaxLen(p.model, "model", 100);
993
- assertStorable(p.version, "version");
994
- assertMaxLen(p.version, "version", 100);
995
- assertStorable(p.resumeWord, "resume word");
996
- assertMaxLen(p.resumeWord, "resume word", 200);
1196
+ // A candidate supplier that returns "" is a broken allocator, not a
1197
+ // collision, so say so here rather than letting the CHECK reject it as an
1198
+ // opaque constraint failure after the retry loop has burned its attempts.
1199
+ if (p.id.length === 0) {
1200
+ throw new Error("persona id candidate is empty; the nickname allocator produced no id");
1201
+ }
1202
+ for (const [field, value] of [
1203
+ ["brand", p.brand],
1204
+ ["model", p.model],
1205
+ ["version", p.version],
1206
+ ]) {
1207
+ assertStorable(value, field);
1208
+ assertMaxLen(value, field, 100);
1209
+ // EXACT trim normalization, checked rather than applied: silently
1210
+ // trimming would make " gpt" and "gpt" the same persona on one call and
1211
+ // different ones on the next, depending on which path normalized. The
1212
+ // tuple is compared byte for byte, so it must arrive already exact.
1213
+ if (value !== value.trim() || value.length === 0) {
1214
+ throw new Error(`${field} must be a trimmed, non-empty string (got ${JSON.stringify(value)})`);
1215
+ }
1216
+ }
997
1217
  assertStorable(p.description, "persona description");
998
1218
  assertMaxLen(p.description, "persona description", 2000);
1219
+ }
1220
+ /**
1221
+ * Insert one candidate nickname. False means the id was taken, so a caller
1222
+ * drawing candidates cannot collapse two personas onto one row (and with it
1223
+ * one read marker, one membership set, and one claim owner).
1224
+ */
1225
+ tryInsertPersona(p) {
1226
+ this.assertLlmIdentity(p);
999
1227
  const info = this.db
1000
1228
  .prepare(`INSERT INTO agents
1001
- (id, is_human, brand, model, version, resume_word, runtime_epoch, description)
1002
- VALUES (@id, 0, @brand, @model, @version, @resumeWord, 1, @description)
1229
+ (id, is_human, brand, model, version, connection_id, description)
1230
+ VALUES (@id, 0, @brand, @model, @version, @connectionId, @description)
1003
1231
  ON CONFLICT(id) DO NOTHING`)
1004
1232
  .run(p);
1005
1233
  return info.changes > 0;
1006
1234
  }
1235
+ /** Draw candidates until one inserts. The six-hex token is 24 bits, so a
1236
+ * collision is ordinary and correctness comes from the atomic insert, not
1237
+ * from the token's width. Exhaustion throws so the enclosing transaction
1238
+ * rolls back rather than committing half a transition. */
1239
+ allocatePersona(a) {
1240
+ for (let attempt = 0; attempt < a.maxAttempts; attempt++) {
1241
+ const id = a.nextCandidateId();
1242
+ if (this.tryInsertPersona({
1243
+ id,
1244
+ brand: a.brand,
1245
+ model: a.model,
1246
+ version: a.version,
1247
+ connectionId: a.connectionId,
1248
+ description: a.description,
1249
+ })) {
1250
+ // Read back rather than synthesize: created_at is a database default.
1251
+ return this.getPersona(id);
1252
+ }
1253
+ }
1254
+ throw new NicknameExhaustedError(a.maxAttempts);
1255
+ }
1007
1256
  /**
1008
- * Bindingless resume: take over a persona and return the NEW epoch.
1257
+ * The one identity operation: create, reuse, or replace, decided by this
1258
+ * process's connection nonce and the exact tuple.
1259
+ *
1260
+ * `expected` is what the PROCESS recorded when it last bound, and it is not a
1261
+ * cache. A row whose connection_id equals our nonce is NOT proof we own it --
1262
+ * a UUID collision produces exactly that -- so an unbound process treats any
1263
+ * such row as a collision to regenerate around, and a bound process requires
1264
+ * agent id AND nonce to match before it will touch anything.
1009
1265
  *
1010
- * The increment is unconditional on success and happens in the same
1011
- * transaction as the credential check, so "latest valid resume wins" is
1012
- * decided by SQLite's write serialization rather than by call ordering in any
1013
- * one process. It runs on EVERY successful attach, not only on a contested
1014
- * one: a caller cannot tell whether the previous runtime is alive, and an
1015
- * attach that skipped the increment would leave that runtime's pollers and
1016
- * captured epochs valid alongside the new one's.
1266
+ * A changed tuple is a REPLACEMENT, not a rename: the old nickname is retired
1267
+ * terminally, keeps its authored history and membership rows, and is never
1268
+ * revived. Marking it retired in the same transaction is what fences the old
1269
+ * identity's in-flight writes, open waits, and detached pollers: they carry
1270
+ * the old agent_id, and that row is now terminal.
1017
1271
  *
1018
- * The two failures are deliberately distinguished: an unknown id says so, and
1019
- * bad credentials on a known id say so. That is the right trade HERE because
1020
- * the threat model is an operator pasting the wrong id, not an attacker
1021
- * probing for valid ones -- an operator who mistyped an id needs to be told
1022
- * that, not handed a uniform "rejected" that sends them hunting for a lost
1023
- * word. This is collision prevention, not authentication.
1272
+ * IMMEDIATE: it reads the binding, then writes under it.
1024
1273
  */
1025
- attachPersona(a) {
1274
+ identifyPersona(a) {
1275
+ const maxAttempts = a.maxAttempts ?? 12;
1276
+ this.assertConnectionId(a.connectionId);
1277
+ if (a.expected !== null)
1278
+ this.assertConnectionId(a.expected.connectionId);
1026
1279
  return this.db
1027
1280
  .transaction(() => {
1028
- const row = this.getPersona(a.id);
1029
- if (!row || row.is_human === 1) {
1030
- throw new Error(`no LLM persona "${a.id}"; check the id, or create_persona for a new one`);
1281
+ // --- never bound: allocate, refusing to adopt a colliding row -------
1282
+ if (a.expected === null) {
1283
+ const clash = this.db
1284
+ .prepare("SELECT id FROM agents WHERE connection_id = ?")
1285
+ .get(a.connectionId);
1286
+ if (clash)
1287
+ throw new ConnectionNonceCollisionError(a.connectionId);
1288
+ const persona = this.allocatePersona({ ...a, maxAttempts });
1289
+ return { persona, bindingReused: false, identityChanged: false };
1031
1290
  }
1032
- // Word FIRST, then tuple. A caller who cannot present the word has no
1033
- // standing to be told anything about the persona, including which model
1034
- // it is; only after the word matches is a tuple mismatch meaningful --
1035
- // and then it is a model change, not a rejected credential.
1036
- if (row.resume_word !== a.resumeWord) {
1037
- throw new Error(`resume rejected for persona "${a.id}": the resume word does not ` +
1038
- `match the one it was created with`);
1291
+ // --- bound: every recorded field must still match -------------------
1292
+ const { agentId, connectionId } = a.expected;
1293
+ const row = this.getPersona(agentId);
1294
+ if (!row)
1295
+ throw new PersonaLostError(agentId, "missing");
1296
+ // Report retirement as retirement: a caller told only "your binding
1297
+ // does not match" would reasonably re-read and retry, and the terminal
1298
+ // wording says that cannot work.
1299
+ if (row.retired_at !== null) {
1300
+ throw new PersonaLostError(agentId, "retired");
1039
1301
  }
1040
- if (row.brand !== a.brand ||
1041
- row.model !== a.model ||
1042
- row.version !== a.version) {
1043
- throw new ModelTupleMismatchError(a.id,
1044
- // Non-null by the is_human check above: the table CHECK keeps an
1045
- // LLM row's tuple whole.
1046
- { brand: row.brand, model: row.model, version: row.version }, { brand: a.brand, model: a.model, version: a.version }, this.presentRoomNames(a.id));
1302
+ if (row.is_human === 1 ||
1303
+ row.connection_id !== connectionId ||
1304
+ connectionId !== a.connectionId) {
1305
+ throw new PersonaLostError(agentId, "binding_mismatch");
1047
1306
  }
1048
- const next = row.runtime_epoch + 1;
1307
+ // Exact string comparison: no case folding, alias table, or version
1308
+ // parsing. "5.0" and "5" are different models to this system because
1309
+ // nothing here is entitled to decide they are the same.
1310
+ if (row.brand === a.brand &&
1311
+ row.model === a.model &&
1312
+ row.version === a.version) {
1313
+ return { persona: row, bindingReused: true, identityChanged: false };
1314
+ }
1315
+ // --- tuple transition ------------------------------------------------
1316
+ const { present } = this.roomMembershipCounts(agentId);
1317
+ const names = this.presentRoomNames(agentId, MAX_PREVIOUS_ROOM_NAMES);
1318
+ // Clear the nonce BEFORE inserting the replacement: both rows would
1319
+ // otherwise hold it at once and the partial unique index would reject
1320
+ // the insert, rolling back a transition that should have succeeded.
1049
1321
  this.db
1050
- .prepare("UPDATE agents SET runtime_epoch = ? WHERE id = ?")
1051
- .run(next, a.id);
1052
- return { epoch: next, persona: { ...row, runtime_epoch: next } };
1322
+ .prepare(`UPDATE agents
1323
+ SET connection_id = NULL, retired_at = datetime('now')
1324
+ WHERE id = ? AND connection_id = ? AND retired_at IS NULL`)
1325
+ .run(agentId, connectionId);
1326
+ this.retireParticipation(agentId);
1327
+ const persona = this.allocatePersona({ ...a, maxAttempts });
1328
+ return {
1329
+ persona,
1330
+ bindingReused: false,
1331
+ identityChanged: true,
1332
+ previousAgentId: agentId,
1333
+ previousRoomCount: present,
1334
+ previousRoomNames: names,
1335
+ previousRoomNamesTruncated: present > names.length,
1336
+ };
1053
1337
  })
1054
1338
  .immediate();
1055
1339
  }
1340
+ /**
1341
+ * Participation effects shared by every terminal retirement, applied only
1342
+ * after the caller's own guarded agents UPDATE proved this runtime still
1343
+ * describes the row, in that same transaction. Memberships are soft-left, so
1344
+ * the rows stay as durable history. Leases and claims are deleted because
1345
+ * their holder is terminal: it can never wake to serve the lease, and can
1346
+ * never release the claim, whose TTL ceiling would otherwise block a live
1347
+ * participant for up to 24h. Deleting claims is the contested call -- it
1348
+ * trades exclusivity away for work that outlived its holder.
1349
+ */
1350
+ retireParticipation(agentId) {
1351
+ this.db
1352
+ .prepare(`UPDATE memberships
1353
+ SET left_at = datetime('now'), last_seen = datetime('now')
1354
+ WHERE agent_id = ? AND left_at IS NULL`)
1355
+ .run(agentId);
1356
+ this.db.prepare("DELETE FROM wait_leases WHERE agent_id = ?").run(agentId);
1357
+ this.db.prepare("DELETE FROM claims WHERE agent_id = ?").run(agentId);
1358
+ }
1359
+ /**
1360
+ * Graceful shutdown retirement, guarded on the EXACT binding this process
1361
+ * recorded.
1362
+ *
1363
+ * Returns false and mutates nothing when any guard differs. Nickname ids are
1364
+ * never reused, so the danger is not that some other process holds this id;
1365
+ * it is that a mismatch means THIS process's recorded state is stale -- the
1366
+ * row was already retired, or transitioned to a new identity, or altered by a
1367
+ * writer outside this file. Acting on a row we no longer describe is exactly
1368
+ * the mutation to refuse, so "a row with this id exists" is never enough.
1369
+ *
1370
+ * A hard kill cannot run this at all. The row then keeps its connection
1371
+ * nonce and its present memberships until the database is replaced. That
1372
+ * stale row confers no authority -- no caller can submit a nonce, and a
1373
+ * first-bind collision regenerates -- but it does inflate member counts,
1374
+ * collect directed work, and block non-forced pruning. There is deliberately
1375
+ * no timeout reaper: an idle live CLI and a dead one are indistinguishable
1376
+ * from here, and a reaper would retire the live one.
1377
+ */
1378
+ retireConnection(a) {
1379
+ this.assertConnectionId(a.connectionId);
1380
+ return this.db
1381
+ .transaction(() => {
1382
+ const info = this.db
1383
+ .prepare(`UPDATE agents
1384
+ SET connection_id = NULL, retired_at = datetime('now')
1385
+ WHERE id = ? AND connection_id = ?
1386
+ AND is_human = 0 AND retired_at IS NULL`)
1387
+ .run(a.agentId, a.connectionId);
1388
+ if (info.changes === 0)
1389
+ return false;
1390
+ this.retireParticipation(a.agentId);
1391
+ return true;
1392
+ })
1393
+ .immediate();
1394
+ }
1395
+ /** Which of `ids` name terminally retired LLM identities. Recipient status
1396
+ * needs this for ids with NO membership row in the room: without it a
1397
+ * retired nickname reads as `unknown`, which tells a poster the name was
1398
+ * wrong when it was real and is simply finished. */
1399
+ retiredIds(ids) {
1400
+ if (ids.length === 0)
1401
+ return new Set();
1402
+ const placeholders = ids.map(() => "?").join(",");
1403
+ const rows = this.db
1404
+ .prepare(`SELECT id FROM agents
1405
+ WHERE retired_at IS NOT NULL AND id IN (${placeholders})`)
1406
+ .all(...ids);
1407
+ return new Set(rows.map((r) => r.id));
1408
+ }
1056
1409
  // --- membership ---------------------------------------------------------
1057
1410
  /**
1058
1411
  * Join (or rejoin) a room: clears any prior leave and refreshes liveness.
1059
1412
  *
1060
- * Presence is PER PERSONA. With one runtime per persona there is no second
1061
- * live seat to reconcile: a takeover fences the loser out instead.
1413
+ * Presence is PER PERSONA. A persona belongs to one connection for its whole
1414
+ * life, so there is never a second live seat to reconcile.
1062
1415
  *
1063
1416
  * `cursorStart` applies ONLY when this call creates the membership. A rejoin
1064
- * must never move a cursor: the whole point of resuming a persona is that its
1065
- * read position survives, and a "start at latest" that also applied on rejoin
1066
- * would silently discard the backlog it was resumed to read.
1417
+ * must never move a cursor: a persona that steps out of a room and back keeps
1418
+ * its read position, and a "start at latest" that also applied on rejoin
1419
+ * would silently discard the backlog it returned to read.
1067
1420
  */
1068
- joinRoom(roomId, agentId, epoch, opts = {}) {
1421
+ joinRoom(roomId, agentId, opts = {}) {
1069
1422
  if (opts.role !== undefined) {
1070
1423
  assertStorable(opts.role, "role");
1071
1424
  assertMaxLen(opts.role, "role", 200);
@@ -1074,10 +1427,10 @@ export class ChatStore {
1074
1427
  // One IMMEDIATE transaction: this is a multi-statement read-then-write path,
1075
1428
  // and a cross-process deleteRoom interleaving between statements otherwise
1076
1429
  // surfaces as an opaque NOT NULL/FK constraint error instead of a clean
1077
- // failure. It also scopes the epoch guard to the same transaction as every
1078
- // write below it.
1430
+ // failure. It also scopes the liveness guard to the same transaction as
1431
+ // every write below it.
1079
1432
  const tx = this.db.transaction(() => {
1080
- this.requireEpoch(agentId, epoch);
1433
+ this.requireLive(agentId);
1081
1434
  // Existence check INSIDE the write transaction: the caller resolved the
1082
1435
  // room earlier, and a cross-process delete_room in that window otherwise
1083
1436
  // surfaces as a raw "FOREIGN KEY constraint failed".
@@ -1117,15 +1470,13 @@ export class ChatStore {
1117
1470
  * handled by not calling this at all. Room-local by construction: the same
1118
1471
  * persona keeps a different role in every other room.
1119
1472
  */
1120
- setRole(roomId, agentId, epoch, role) {
1473
+ setRole(roomId, agentId, role) {
1121
1474
  assertStorable(role, "role");
1122
1475
  assertMaxLen(role, "role", 200);
1123
1476
  assertRole(role);
1124
1477
  const tx = this.db.transaction(() => {
1125
- this.requireEpoch(agentId, epoch);
1126
- this.requireRoom(roomId);
1127
- // A role describes what you are IN THIS ROOM; you cannot hold one while
1128
- // absent.
1478
+ this.requireLive(agentId);
1479
+ // A role describes participation in this room.
1129
1480
  this.requirePresent(roomId, agentId);
1130
1481
  // Refresh last_seen in the SAME statement. A role change is a deliberate
1131
1482
  // act in THIS room, so the seat is demonstrably attended here; leaving
@@ -1135,8 +1486,10 @@ export class ChatStore {
1135
1486
  .prepare(`UPDATE memberships SET role = ?, last_seen = datetime('now')
1136
1487
  WHERE room_id = ? AND agent_id = ?`)
1137
1488
  .run(role, roomId, agentId);
1489
+ // requirePresent proved this row exists in the same transaction.
1138
1490
  if (info.changes === 0) {
1139
- throw new Error(`you are not a member of room ${roomId}; join_room it first`);
1491
+ throw new Error(`internal invariant violated: membership for room ${roomId} passed ` +
1492
+ "the presence check and then matched no row; the database may be corrupt");
1140
1493
  }
1141
1494
  });
1142
1495
  tx.immediate();
@@ -1152,9 +1505,9 @@ export class ChatStore {
1152
1505
  * Soft leave: keep the membership row (and its read position and role) but
1153
1506
  * mark the persona not present. Rejoining resumes exactly where it left off.
1154
1507
  */
1155
- leaveRoom(roomId, agentId, epoch) {
1508
+ leaveRoom(roomId, agentId) {
1156
1509
  const tx = this.db.transaction(() => {
1157
- this.requireEpoch(agentId, epoch);
1510
+ this.requireLive(agentId);
1158
1511
  const info = this.db
1159
1512
  .prepare(`UPDATE memberships SET left_at = datetime('now'), last_seen = datetime('now')
1160
1513
  WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
@@ -1164,42 +1517,29 @@ export class ChatStore {
1164
1517
  return tx.immediate();
1165
1518
  }
1166
1519
  /**
1167
- * Mark a persona alive in a room, clearing that room's left_at (an actively
1168
- * acting runtime re-asserts presence there).
1520
+ * Mark a persona alive in a room it is PRESENT in. Liveness only: a room this
1521
+ * persona explicitly left stays left, because rejoining is a state transition
1522
+ * join_room owns and an incidental heartbeat must not perform it silently.
1523
+ * A room with no present membership is simply not refreshed. The method is
1524
+ * void because heartbeat callers do not act on whether an update matched.
1169
1525
  *
1170
- * EPOCH-FENCED like any other write. A liveness touch looks harmless, but it
1526
+ * LIVE-IDENTITY-FENCED like any other write. A liveness touch looks harmless, but it
1171
1527
  * is what makes a persona read as present/active to every other agent and to
1172
1528
  * recipient status; an unfenced touch would let a runtime that has already
1173
1529
  * lost the persona keep publishing it as an available listener, which is the
1174
- * false-listener-truth failure the epoch exists to close. Throwing here is
1530
+ * false-listener-truth failure this guard exists to close. Throwing here is
1175
1531
  * correct: the caller treats liveness as best-effort and the next real
1176
1532
  * operation reports persona_lost.
1177
1533
  */
1178
- touch(roomId, agentId, epoch) {
1534
+ touch(roomId, agentId) {
1179
1535
  const tx = this.db.transaction(() => {
1180
- this.requireEpoch(agentId, epoch);
1536
+ this.requireLive(agentId);
1181
1537
  this.db
1182
- .prepare(`UPDATE memberships SET last_seen = datetime('now'), left_at = NULL
1183
- WHERE room_id = ? AND agent_id = ?`)
1184
- .run(roomId, agentId);
1185
- });
1186
- tx.immediate();
1187
- }
1188
- /**
1189
- * Refresh activity for one room captured by a cross-room operation, WITHOUT
1190
- * rejoining it: a room this persona explicitly left stays left. Returns false
1191
- * when there was no present membership to refresh.
1192
- */
1193
- touchJoinedRoom(roomId, agentId, epoch) {
1194
- const tx = this.db.transaction(() => {
1195
- this.requireEpoch(agentId, epoch);
1196
- const info = this.db
1197
1538
  .prepare(`UPDATE memberships SET last_seen = datetime('now')
1198
1539
  WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
1199
1540
  .run(roomId, agentId);
1200
- return info.changes > 0;
1201
1541
  });
1202
- return tx.immediate();
1542
+ tx.immediate();
1203
1543
  }
1204
1544
  /**
1205
1545
  * Open an in-turn wait lease: this persona has a blocking catch_up pending in
@@ -1207,49 +1547,45 @@ export class ChatStore {
1207
1547
  * leave a permanent "watching" ghost. Expired rows for the room are reaped in
1208
1548
  * passing.
1209
1549
  *
1210
- * The upsert writes the CALLER'S epoch into the row (`epoch = excluded.epoch`
1211
- * on conflict). Leaving the old value in place would invert the guard in
1212
- * endWaitLease: the winner's row would carry the loser's epoch, so the
1213
- * loser's cleanup would match and delete it while the winner's own cleanup
1214
- * would not.
1550
+ * One row per (room, agent) and no tenure column: an agent_id has exactly one
1551
+ * tenure, so there is no second tenure of the same id whose lease this could
1552
+ * be confused with. Concurrent waits from the SAME identity share the row and
1553
+ * are counted process-side; the last waiter out closes it.
1215
1554
  */
1216
- beginWaitLease(roomId, agentId, epoch, ttlSeconds) {
1555
+ beginWaitLease(roomId, agentId, ttlSeconds) {
1217
1556
  const tx = this.db.transaction(() => {
1218
- this.requireEpoch(agentId, epoch);
1219
- this.requireRoom(roomId);
1220
- // A lease ADVERTISES this persona as watching the room. An absent
1221
- // persona advertising a live watch is the exact contradiction the
1222
- // present-membership rule exists to remove.
1557
+ this.requireLive(agentId);
1558
+ // A lease must not advertise an absent persona as watching.
1223
1559
  this.requirePresent(roomId, agentId);
1224
1560
  this.db
1225
1561
  .prepare("DELETE FROM wait_leases WHERE room_id = ? AND expires_at <= datetime('now')")
1226
1562
  .run(roomId);
1227
1563
  this.db
1228
- .prepare(`INSERT INTO wait_leases (room_id, agent_id, epoch, expires_at)
1229
- VALUES (?, ?, ?, datetime('now', '+' || ? || ' seconds'))
1564
+ .prepare(
1565
+ // Overlapping waits share one row: keep the aggregate's first start
1566
+ // and furthest deadline rather than letting a later short wait
1567
+ // advertise that the still-open long wait expired.
1568
+ `INSERT INTO wait_leases (room_id, agent_id, expires_at)
1569
+ VALUES (?, ?, datetime('now', '+' || ? || ' seconds'))
1230
1570
  ON CONFLICT(room_id, agent_id) DO UPDATE SET
1231
- epoch = excluded.epoch,
1232
- started_at = datetime('now'),
1233
- expires_at = excluded.expires_at`)
1234
- .run(roomId, agentId, epoch, Math.max(1, Math.floor(ttlSeconds)));
1571
+ expires_at = max(wait_leases.expires_at, excluded.expires_at)`)
1572
+ .run(roomId, agentId, Math.max(1, Math.floor(ttlSeconds)));
1235
1573
  });
1236
1574
  tx.immediate();
1237
1575
  }
1238
1576
  /**
1239
1577
  * Close an in-turn wait lease (normal return, timeout, or abort alike).
1240
1578
  *
1241
- * Guarded on the epoch the caller CAPTURED when it opened the lease, never on
1242
- * the persona's current epoch. A fenced-out runtime still runs its finally
1243
- * block, and that cleanup can land after the winner has already replaced the
1244
- * row; an unguarded DELETE would then remove the winner's live lease and
1245
- * report it as not watching. Deliberately NOT wrapped in requireEpoch: a lost
1246
- * runtime must still be able to clean up after itself, it just must not touch
1247
- * anything that is no longer its own.
1579
+ * Deliberately NOT wrapped in requireLive: a retired identity must still be
1580
+ * able to clean up after itself. It cannot reach anything that is not its
1581
+ * own, because the row is keyed by agent_id and a successor identity is a
1582
+ * DIFFERENT agent_id -- so a late cleanup from a superseded persona can only
1583
+ * ever delete that persona's own row.
1248
1584
  */
1249
- endWaitLease(roomId, agentId, epoch) {
1585
+ endWaitLease(roomId, agentId) {
1250
1586
  this.db
1251
- .prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ? AND epoch = ?")
1252
- .run(roomId, agentId, epoch);
1587
+ .prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ?")
1588
+ .run(roomId, agentId);
1253
1589
  }
1254
1590
  getMembership(roomId, agentId) {
1255
1591
  return this.db
@@ -1310,12 +1646,13 @@ export class ChatStore {
1310
1646
  // role comes from the MEMBERSHIP, not the persona: it is what this persona
1311
1647
  // is in THIS room.
1312
1648
  const cols = `SELECT a.id, a.brand, a.model, a.version, a.is_human,
1649
+ a.retired_at,
1313
1650
  m.role AS role,
1314
1651
  substr(a.description, 1, ${PREVIEW}) AS description,
1315
1652
  CASE WHEN length(a.description) > ${PREVIEW} THEN 1 ELSE 0 END AS description_cut,
1316
1653
  m.joined_at, m.rowid AS _rid,
1317
1654
  m.last_read_seq, m.last_seen, m.left_at,
1318
- (strftime('%s','now') - strftime('%s', m.last_seen)) AS idle_seconds,
1655
+ (unixepoch('now') - unixepoch(m.last_seen)) AS idle_seconds,
1319
1656
  EXISTS(SELECT 1 FROM wait_leases wl
1320
1657
  WHERE wl.room_id = m.room_id AND wl.agent_id = m.agent_id
1321
1658
  AND wl.expires_at > datetime('now')) AS watching
@@ -1369,18 +1706,26 @@ export class ChatStore {
1369
1706
  const page = hasMore ? rows.slice(0, lim) : rows;
1370
1707
  const threshold = activeWithinMinutes * 60;
1371
1708
  const mapped = page.map((r) => {
1372
- const { left_at, description_cut, _rid, watching, ...rest } = r;
1709
+ const { left_at, retired_at, description_cut, _rid, watching, ...rest } = r;
1373
1710
  void _rid;
1374
- const isWatching = watching === 1;
1711
+ const retired = retired_at !== null;
1712
+ // Retirement soft-leaves memberships and deletes leases in its own
1713
+ // transaction, so these are already false by the data. Forcing them is
1714
+ // for the writers this file cannot reach: a direct SQL writer can leave
1715
+ // a retired row present, and a listing that showed it as a live member
1716
+ // would send peers work it can never read.
1717
+ const isWatching = !retired && watching === 1;
1375
1718
  return {
1376
1719
  ...rest,
1377
1720
  is_human: rest.is_human === 1,
1378
1721
  ...(description_cut ? { description_truncated: true } : {}),
1379
- present: left_at === null,
1380
- active: left_at === null &&
1722
+ present: !retired && left_at === null,
1723
+ active: !retired &&
1724
+ left_at === null &&
1381
1725
  (isWatching ||
1382
1726
  (r.idle_seconds !== null && r.idle_seconds <= threshold)),
1383
1727
  watching: isWatching,
1728
+ retired,
1384
1729
  };
1385
1730
  });
1386
1731
  const { rows: agents, sizeTrimmed } = fitRows(mapped, LIST_ROW_BUDGET);
@@ -1450,11 +1795,10 @@ export class ChatStore {
1450
1795
  * the guard). The reject baseline is the TOKEN, unlike the accept path's
1451
1796
  * cursor-relative crossed. opts.crossedPreviewChars additionally returns
1452
1797
  * crossed previews on an ACCEPTED post. A post never consumes an unseen peer
1453
- * message. After an accepted post, the posting cursor and sibling cursors at
1454
- * the proven safe peer floor are normalized through the new own row so their
1455
- * recurring probes do not rescan that suffix.
1798
+ * message. After an accepted post, the persona cursor is normalized through
1799
+ * the new own row when it is already at the proven safe peer floor.
1456
1800
  */
1457
- postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq, epoch, opts = {}) {
1801
+ postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq, opts = {}) {
1458
1802
  // Reject unstorable text BEFORE the transaction: a body with an embedded
1459
1803
  // NUL reads back truncated (SQLite substr/length stop at NUL) and catch_up
1460
1804
  // would advance the marker past the lost tail. mentions are agent ids
@@ -1500,16 +1844,12 @@ export class ChatStore {
1500
1844
  }
1501
1845
  const ifToken = opts.ifLastReadSeq ?? null;
1502
1846
  const tx = this.db.transaction(() => {
1503
- // FIRST, before the dedup lookup: a fenced-out runtime must not even be
1504
- // told the seq of a message it posted in a previous tenure, and must
1847
+ // FIRST, before the dedup lookup: a retired identity must not even be
1848
+ // told the seq of a message it posted before retirement, and must
1505
1849
  // certainly not insert a new one.
1506
- this.requireEpoch(agentId, epoch);
1507
- // The caller's room reference predates this transaction; a concurrent
1508
- // delete_room otherwise surfaces as a raw FK failure on the INSERT.
1509
- this.requireRoom(roomId);
1510
- // Authoring is the loudest form of participation. A persona every peer
1511
- // has been told is absent must not appear in the transcript.
1512
- this.requirePresent(roomId, agentId);
1850
+ this.requireLive(agentId);
1851
+ // Presence, room existence, and the cursor share one membership read.
1852
+ const membership = this.requirePresent(roomId, agentId);
1513
1853
  // Lost-response retry: one indexed lookup only when the caller opted in.
1514
1854
  // It precedes CAS/reference validation so a committed first attempt is
1515
1855
  // recoverable even if room state or a referenced parent later changed.
@@ -1560,8 +1900,7 @@ export class ChatStore {
1560
1900
  (!Number.isSafeInteger(ifToken) || ifToken < 0)) {
1561
1901
  throw new Error("if_last_read_seq must be a non-negative safe integer");
1562
1902
  }
1563
- const cursor = this.getCursor(roomId, agentId);
1564
- const from = cursor?.last_read_seq ?? 0;
1903
+ const from = membership.last_read_seq;
1565
1904
  if (ifToken !== null) {
1566
1905
  // A future/wrong-cursor token made the predicate `seq > token` empty
1567
1906
  // and silently disabled the CAS while unread messages still existed.
@@ -1634,15 +1973,20 @@ export class ChatStore {
1634
1973
  throw new Error(`supersedes_seq ${supersedesSeq} was written by ${target.agent_id}; you can only supersede your own messages`);
1635
1974
  }
1636
1975
  }
1637
- // Crossing report: computed before the insert so "unread" excludes the
1638
- // message being posted. The directedAt pair binds FIRST (it sits in the
1639
- // SELECT list, ahead of the WHERE placeholders).
1640
- const crossing = this.db
1641
- .prepare(`SELECT COUNT(*) AS c, MIN(seq) AS mn, MAX(seq) AS mx,
1642
- SUM(CASE WHEN ${directedAt("messages")} THEN 1 ELSE 0 END) AS d
1643
- FROM messages
1644
- WHERE room_id = ? AND seq > ? AND agent_id != ?`)
1645
- .get(agentId, agentId, roomId, from, agentId);
1976
+ // A surviving CAS already proved stale.c === 0 after a token no newer
1977
+ // than `from`, so it also proved there are no crossings after `from`.
1978
+ // Ordinary posts still need the cursor-relative aggregate.
1979
+ let crossing = { c: 0, mn: null, mx: null, d: null };
1980
+ if (ifToken === null) {
1981
+ // The directedAt pair binds FIRST (it sits in the SELECT list, ahead of
1982
+ // the WHERE placeholders).
1983
+ crossing = this.db
1984
+ .prepare(`SELECT COUNT(*) AS c, MIN(seq) AS mn, MAX(seq) AS mx,
1985
+ SUM(CASE WHEN ${directedAt("messages")} THEN 1 ELSE 0 END) AS d
1986
+ FROM messages
1987
+ WHERE room_id = ? AND seq > ? AND agent_id != ?`)
1988
+ .get(agentId, agentId, roomId, from, agentId);
1989
+ }
1646
1990
  const { next } = this.db
1647
1991
  .prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM messages WHERE room_id = ?")
1648
1992
  .get(roomId);
@@ -1706,14 +2050,21 @@ export class ChatStore {
1706
2050
  // reported length (an emoji counts once). Deciding on codepointLen (the full
1707
2051
  // codepoint count) rather than a UTF-16 length keeps the threshold in the
1708
2052
  // same unit as the cut.
1709
- const truncate = previewChars !== undefined && codepointLen(r) > previewChars;
2053
+ // The SQL fetch itself is capped before this mapper runs. body_len is the
2054
+ // exact full UTF-16 length stamped at write time, so it detects that cut
2055
+ // without scanning the body again. A cut JSON body must stay raw: parsing
2056
+ // a valid-looking prefix can change it (a huge numeric prefix becomes
2057
+ // Infinity, then serializes as null) while hiding that bytes remain.
2058
+ const fetchedTruncate = r.body.length < r.body_len;
2059
+ const truncate = fetchedTruncate ||
2060
+ (previewChars !== undefined && codepointLen(r) > previewChars);
1710
2061
  // A truncated body is returned as a raw (possibly partial) string even for
1711
2062
  // json: a sliced JSON string does not parse, so the caller must fetch the
1712
2063
  // full body with get_message. `truncated`/`length` signal exactly that.
1713
- // A body larger than the fetch cap arrives here already cut; it can never
1714
- // fit the byte budget anyway, so shrinkToFit re-flags it downstream.
1715
2064
  const content = truncate
1716
- ? cutToCodepoints(r.body, previewChars)
2065
+ ? previewChars === undefined
2066
+ ? r.body
2067
+ : cutToCodepoints(r.body, previewChars)
1717
2068
  : r.format === "json"
1718
2069
  ? safeParse(r.body)
1719
2070
  : r.body;
@@ -1751,7 +2102,7 @@ export class ChatStore {
1751
2102
  * measured size fits -- a single code-unit cut under-counts JSON escaping,
1752
2103
  * which is how control-heavy room names kept escaping the budget. Every
1753
2104
  * stage measures the real serialized output, so for any budget >=
1754
- * STUB_ALLOWANCE the result is guaranteed to fit.
2105
+ * STUB_ALLOWANCE - 2 the result is guaranteed to fit.
1755
2106
  */
1756
2107
  shrinkToFit(r, previewChars, budget, map) {
1757
2108
  const m = map(r, previewChars);
@@ -1775,8 +2126,11 @@ export class ChatStore {
1775
2126
  // there. Stage 4 sets this explicitly, but stage 2's escaping correction can
1776
2127
  // reach keep=0 on its own for a control-heavy body, and a caller that reads
1777
2128
  // `oversized` to decide "fetch this with get_message" must not miss those.
1778
- if (h.content === "" && codepointLen(r) > 0)
2129
+ if (h.content === "" && codepointLen(r) > 0) {
1779
2130
  h.oversized = true;
2131
+ // The flag changes the size that gates mention shedding and stubbing.
2132
+ sz = JSON.stringify(head).length;
2133
+ }
1780
2134
  // Stage 3: shed mentions (down to none if needed); size can live
1781
2135
  // entirely in a legal `to` list. Flags set before the deciding measure.
1782
2136
  if (sz > budget && Array.isArray(h.to) && h.to.length > 0) {
@@ -1944,9 +2298,13 @@ export class ChatStore {
1944
2298
  // but returning it is the only progress-safe interpretation of SQLite's
1945
2299
  // one-CODEPOINT window; shrinking it to "" made next_offset repeat forever.
1946
2300
  const firstCodepointUnits = (chunk.codePointAt(0) ?? 0) > 0xffff ? 2 : 1;
1947
- while (chunk.length > firstCodepointUnits &&
1948
- JSON.stringify(chunk).length - 2 > cap) {
1949
- const ratio = cap / (JSON.stringify(chunk).length - 2);
2301
+ // Serializing is the expensive step here, so measure each chunk once and
2302
+ // re-measure only after cutting it.
2303
+ while (chunk.length > firstCodepointUnits) {
2304
+ const serialized = JSON.stringify(chunk).length - 2;
2305
+ if (serialized <= cap)
2306
+ break;
2307
+ const ratio = cap / serialized;
1950
2308
  chunk = safeCut(chunk, Math.min(chunk.length - 1, Math.max(1, Math.floor(chunk.length * ratio))));
1951
2309
  }
1952
2310
  // Codepoints consumed by this slice (bounded, so counting is cheap).
@@ -1989,7 +2347,7 @@ export class ChatStore {
1989
2347
  .get(roomId);
1990
2348
  const rows = this.db
1991
2349
  .prepare(`SELECT agent_id, last_read_seq, left_at,
1992
- (strftime('%s','now') - strftime('%s', last_seen)) AS idle_seconds,
2350
+ (unixepoch('now') - unixepoch(last_seen)) AS idle_seconds,
1993
2351
  EXISTS(SELECT 1 FROM wait_leases wl
1994
2352
  WHERE wl.room_id = memberships.room_id
1995
2353
  AND wl.agent_id = memberships.agent_id
@@ -1998,9 +2356,28 @@ export class ChatStore {
1998
2356
  WHERE room_id = ? AND agent_id IN (${placeholders})`)
1999
2357
  .all(roomId, ...ids);
2000
2358
  const byId = new Map(rows.map((r) => [r.agent_id, r]));
2359
+ // Retirement is a property of the IDENTITY, not of a membership, so it is
2360
+ // resolved for every requested id -- including ids with no membership row
2361
+ // here, which would otherwise report `unknown` and tell a poster its
2362
+ // recipient never existed when in fact it existed and is finished.
2363
+ const retired = this.retiredIds(ids);
2001
2364
  const threshold = activeWithinMinutes * 60;
2002
2365
  return ids.map((id) => {
2003
2366
  const r = byId.get(id);
2367
+ if (retired.has(id)) {
2368
+ return {
2369
+ id,
2370
+ status: "retired",
2371
+ present: false,
2372
+ idle_seconds: null,
2373
+ // Its final cursor is retained history and is still the truthful
2374
+ // answer to "how far did it get", so report it when a membership
2375
+ // exists. marker_behind stays meaningful for the same reason.
2376
+ last_read_seq: r ? r.last_read_seq : null,
2377
+ marker_behind: r ? Math.max(0, latest - r.last_read_seq) : null,
2378
+ watching: false,
2379
+ };
2380
+ }
2004
2381
  if (!r) {
2005
2382
  return {
2006
2383
  id,
@@ -2054,8 +2431,8 @@ export class ChatStore {
2054
2431
  SELECT mb.agent_id AS agent_id, mb.room_id AS room_id, r.name AS room_name,
2055
2432
  COUNT(*) AS directed_unread,
2056
2433
  MIN(g.seq) AS oldest_seq,
2057
- MIN(CAST(strftime('%s', g.created_at) AS INTEGER)) AS oldest_unix,
2058
- (strftime('%s','now') - strftime('%s', mb.last_seen)) AS idle_seconds,
2434
+ MIN(unixepoch(g.created_at)) AS oldest_unix,
2435
+ (unixepoch('now') - unixepoch(mb.last_seen)) AS idle_seconds,
2059
2436
  mb.last_read_seq AS last_read_seq
2060
2437
  FROM memberships mb
2061
2438
  JOIN rooms r ON r.id = mb.room_id
@@ -2122,82 +2499,88 @@ export class ChatStore {
2122
2499
  * page them via get_message.
2123
2500
  */
2124
2501
  getThread(roomId, seq, maxDepth = 3, previewChars) {
2125
- const focalRow = this.getRawMessage(roomId, seq);
2126
- if (!focalRow)
2127
- return undefined;
2128
- const mapPlain = (r, pc) => this.rowToMessage(r, pc);
2129
- const budget = DEFAULT_MAX_BYTES - THREAD_ENVELOPE;
2130
- // Reserve the parent's slot before spending on the focal message: a stub
2131
- // allowance when a parent exists, 4 chars of literal null otherwise. The
2132
- // trailing 2 covers an empty replies array.
2133
- const parentSeq = focalRow.reply_to_seq;
2134
- const parentReserve = parentSeq === null ? 4 : STUB_ALLOWANCE + 2;
2135
- const message = this.shrinkToFit(focalRow, undefined, budget - parentReserve - 2, mapPlain);
2136
- let remaining = budget - JSON.stringify(message).length;
2137
- let parent = null;
2138
- if (parentSeq !== null) {
2139
- const parentRow = this.getRawMessage(roomId, parentSeq);
2140
- if (parentRow) {
2141
- // Leave a stub allowance (plus array brackets) for the replies when
2142
- // more than that remains; otherwise the parent gets a stub itself.
2143
- parent = this.shrinkToFit(parentRow, undefined, Math.max(STUB_ALLOWANCE, remaining - STUB_ALLOWANCE - 2), mapPlain);
2502
+ const tx = this.db.transaction(() => {
2503
+ const focalRow = this.getRawMessage(roomId, seq);
2504
+ if (!focalRow)
2505
+ return undefined;
2506
+ const mapPlain = (r, pc) => this.rowToMessage(r, pc);
2507
+ const budget = DEFAULT_MAX_BYTES - THREAD_ENVELOPE;
2508
+ // Reserve the parent's slot before spending on the focal message: a stub
2509
+ // allowance when a parent exists, 4 chars of literal null otherwise. The
2510
+ // trailing 2 covers an empty replies array.
2511
+ const parentSeq = focalRow.reply_to_seq;
2512
+ const parentReserve = parentSeq === null ? 4 : STUB_ALLOWANCE + 2;
2513
+ const message = this.shrinkToFit(focalRow, undefined, budget - parentReserve - 2, mapPlain);
2514
+ let remaining = budget - JSON.stringify(message).length;
2515
+ let parent = null;
2516
+ if (parentSeq !== null) {
2517
+ const parentRow = this.getRawMessage(roomId, parentSeq);
2518
+ if (parentRow) {
2519
+ // Leave a stub allowance (plus array brackets) for the replies when
2520
+ // more than that remains; otherwise the parent gets a stub itself.
2521
+ parent = this.shrinkToFit(parentRow, undefined, Math.max(STUB_ALLOWANCE, remaining - STUB_ALLOWANCE - 2), mapPlain);
2522
+ }
2144
2523
  }
2145
- }
2146
- remaining -= parent ? JSON.stringify(parent).length : 4;
2147
- const cap = 500;
2148
- // Recursive walk of the reply subtree. `path` (zero-padded seq per level)
2149
- // orders siblings numerically and yields pre-order DFS when sorted. Fetch
2150
- // cap+1 rows to detect (without a separate COUNT) that more were available,
2151
- // but memory-bound via fetchBounded so 500 large replies do not all
2152
- // materialize (~50 MB) just to trim to the thread budget: it stops after
2153
- // ~budget raw body plus a sentinel. When it stops by SIZE, replies_capped
2154
- // may under-report (byte_limited then carries "more replies exist"); when
2155
- // replies are small it fetches the full cap+1 and reports capping exactly.
2156
- const { rows, exhausted } = this.fetchBounded(this.db.prepare(`WITH RECURSIVE descendants(seq, depth, path) AS (
2157
- SELECT g.seq, 1, printf('%010d', g.seq)
2158
- FROM messages g
2159
- WHERE g.room_id = @room AND g.reply_to_seq = @root
2160
- UNION ALL
2161
- SELECT c.seq, d.depth + 1, d.path || '/' || printf('%010d', c.seq)
2162
- FROM messages c
2163
- JOIN descendants d ON c.reply_to_seq = d.seq
2164
- WHERE c.room_id = @room AND d.depth < @maxDepth
2165
- )
2166
- SELECT ${messageCols(DEFAULT_MAX_BYTES)}, d.depth AS depth
2167
- FROM descendants d
2168
- JOIN messages g ON g.room_id = @room AND g.seq = d.seq
2169
- LEFT JOIN agents a ON a.id = g.agent_id
2170
- LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
2171
- ORDER BY d.path
2172
- LIMIT @lim`), [{ room: roomId, root: seq, maxDepth, lim: cap + 1 }], Math.max(STUB_ALLOWANCE, remaining));
2173
- const replies_capped = rows.length > cap;
2174
- // Below a stub allowance boundByBytes cannot guarantee even its head row
2175
- // fits; omit the replies instead of delivering an over-budget response
2176
- // (byte_limited says they exist; get_thread on a reply seq fetches them).
2177
- let replies = [];
2178
- let byteLimited = false;
2179
- if (rows.length > 0 && remaining < STUB_ALLOWANCE + 2) {
2180
- byteLimited = true;
2181
- }
2182
- else if (rows.length > 0) {
2183
- ({ messages: replies, byteLimited } = this.boundByBytes(rows.slice(0, cap), previewChars, remaining, (r, pc) => ({
2184
- ...this.rowToMessage(r, pc),
2185
- depth: r.depth,
2186
- })));
2187
- }
2188
- // If fetchBounded stopped on the raw-byte budget (exhausted:false), replies
2189
- // were left unfetched even when boundByBytes fit everything it got (a
2190
- // preview_chars cut shrank them all): flag byte_limited so the omission is
2191
- // never silent. get_thread has no reply-offset param; the recourse is
2192
- // get_thread on a reply seq, which byte_limited signals is needed.
2193
- byteLimited = byteLimited || !exhausted;
2194
- return {
2195
- message,
2196
- parent,
2197
- replies,
2198
- replies_capped,
2199
- ...(byteLimited ? { byte_limited: true } : {}),
2200
- };
2524
+ remaining -= parent ? JSON.stringify(parent).length : 4;
2525
+ const cap = 500;
2526
+ // `path` uses the fixed width needed through Number.MAX_SAFE_INTEGER, the
2527
+ // supported writer bound, so sorting it yields numeric pre-order DFS.
2528
+ // The recursive ORDER/LIMIT keeps at most cap+1 rows for the expensive
2529
+ // message joins and final sort. It bounds the recursive result table, not
2530
+ // candidate discovery: wide fanout may still be enumerated and sorted.
2531
+ // fetchBounded separately stops large bodies at the response budget.
2532
+ const { rows, exhausted } = this.fetchBounded(this.db.prepare(`WITH RECURSIVE descendants(seq, depth, path) AS (
2533
+ SELECT g.seq, 1, printf('%016d', g.seq)
2534
+ FROM messages g
2535
+ WHERE g.room_id = @room AND g.reply_to_seq = @root
2536
+ UNION ALL
2537
+ SELECT c.seq, d.depth + 1,
2538
+ d.path || '/' || printf('%016d', c.seq) AS path
2539
+ FROM descendants d
2540
+ CROSS JOIN messages c INDEXED BY idx_messages_reply
2541
+ WHERE c.reply_to_seq = d.seq
2542
+ AND c.room_id = @room
2543
+ AND d.depth < @maxDepth
2544
+ ORDER BY path
2545
+ LIMIT @lim
2546
+ )
2547
+ SELECT ${messageCols(DEFAULT_MAX_BYTES)}, d.depth AS depth
2548
+ FROM descendants d
2549
+ CROSS JOIN messages g
2550
+ LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
2551
+ WHERE g.room_id = @room AND g.seq = d.seq
2552
+ ORDER BY d.path
2553
+ LIMIT @lim`), [{ room: roomId, root: seq, maxDepth, lim: cap + 1 }], Math.max(STUB_ALLOWANCE, remaining));
2554
+ const replies_capped = rows.length > cap;
2555
+ // Below a stub allowance boundByBytes cannot guarantee even its head row
2556
+ // fits; omit the replies instead of delivering an over-budget response
2557
+ // (byte_limited says they exist; get_thread on a reply seq fetches them).
2558
+ let replies = [];
2559
+ let byteLimited = false;
2560
+ if (rows.length > 0 && remaining < STUB_ALLOWANCE + 2) {
2561
+ byteLimited = true;
2562
+ }
2563
+ else if (rows.length > 0) {
2564
+ ({ messages: replies, byteLimited } = this.boundByBytes(rows.slice(0, cap), previewChars, remaining, (r, pc) => ({
2565
+ ...this.rowToMessage(r, pc),
2566
+ depth: r.depth,
2567
+ })));
2568
+ }
2569
+ // If fetchBounded stopped on the raw-byte budget (exhausted:false), replies
2570
+ // were left unfetched even when boundByBytes fit everything it got (a
2571
+ // preview_chars cut shrank them all): flag byte_limited so the omission is
2572
+ // never silent. get_thread has no reply-offset param; the recourse is
2573
+ // get_thread on a reply seq, which byte_limited signals is needed.
2574
+ byteLimited = byteLimited || !exhausted;
2575
+ return {
2576
+ message,
2577
+ parent,
2578
+ replies,
2579
+ replies_capped,
2580
+ ...(byteLimited ? { byte_limited: true } : {}),
2581
+ };
2582
+ });
2583
+ return tx.deferred();
2201
2584
  }
2202
2585
  /** Count of messages newer than the marker that the agent did NOT write. */
2203
2586
  unreadCount(roomId, lastReadSeq, agentId) {
@@ -2218,33 +2601,37 @@ export class ChatStore {
2218
2601
  * lock the way catchUp's IMMEDIATE transaction would. Throws when the
2219
2602
  * membership is gone (room deleted mid-wait), same as catchUp.
2220
2603
  *
2221
- * EPOCH-FENCED, and this is what bounds how long a fenced-out wait keeps
2604
+ * FENCED ON LIVENESS, and this is what bounds how long a dead wait keeps
2222
2605
  * sitting there. The advancing read on a hit was always fenced, but a QUIET
2223
- * stale wait never reaches it: with no epoch here, a runtime taken over at
2606
+ * stale wait never reaches it: with no check here, an identity retired at
2224
2607
  * second 3 of a 25-second wait went right on waiting, holding a lease that
2225
2608
  * told peers it was watching, and learned nothing until traffic arrived or
2226
2609
  * the deadline expired. Checking each probe caps that at one interval.
2227
2610
  *
2228
- * The epoch rides as returned DATA anchored on the persona row, not as a
2229
- * WHERE predicate: as a predicate a fenced runtime would get "no row", which
2611
+ * retired_at rides as returned DATA anchored on the persona row, not as a
2612
+ * WHERE predicate: as a predicate a dead identity would get "no row", which
2230
2613
  * this method already means "the room or membership is gone" -- a wrong and
2231
2614
  * actively misleading diagnosis. Anchoring on agents keeps the two apart.
2232
2615
  */
2233
- unreadProbe(roomId, agentId, epoch) {
2616
+ unreadProbe(roomId, agentId) {
2234
2617
  const row = this.db
2235
- .prepare(`SELECT a.runtime_epoch AS current_epoch,
2236
- (SELECT mb.last_read_seq FROM memberships mb
2237
- WHERE mb.room_id = @room AND mb.agent_id = @agent)
2238
- AS last_read_seq
2239
- FROM agents a WHERE a.id = @agent`)
2618
+ .prepare(`SELECT a.retired_at, mb.last_read_seq, mb.left_at
2619
+ FROM agents a
2620
+ LEFT JOIN memberships mb
2621
+ ON mb.room_id = @room AND mb.agent_id = a.id
2622
+ WHERE a.id = @agent`)
2240
2623
  .get({ room: roomId, agent: agentId });
2624
+ // Same identity fence as requireLive: a quiet in-call wait must stop after
2625
+ // retirement rather than keep advertising a seat that can never act.
2241
2626
  if (!row)
2242
- throw new PersonaLostError(agentId, epoch, null);
2243
- if (row.current_epoch !== epoch) {
2244
- throw new PersonaLostError(agentId, epoch, row.current_epoch);
2245
- }
2627
+ throw new PersonaLostError(agentId, "missing");
2628
+ if (row.retired_at !== null)
2629
+ throw new PersonaLostError(agentId, "retired");
2246
2630
  if (row.last_read_seq === null)
2247
2631
  throw new Error("not a member of this room");
2632
+ if (row.left_at !== null) {
2633
+ throw new Error(`persona "${agentId}" LEFT room ${roomId} while waiting; rejoin before waiting on it again`);
2634
+ }
2248
2635
  // A wait only needs a yes/no wake signal. COUNT(*) rescanned the complete
2249
2636
  // unread tail twice per second per wait (including a large self-authored
2250
2637
  // tail that never advances); the room/seq index lets this stop at one row.
@@ -2263,8 +2650,9 @@ export class ChatStore {
2263
2650
  * and catch_up's rooms_with_unread disclosure on an empty read. Rooms this
2264
2651
  * persona soft-left are muted by the membership join. excludeRoomId drops the
2265
2652
  * room just read (catch_up's summary lists OTHER rooms). Fetches limit+1 to
2266
- * report truncation without a tail COUNT. Read-only, so it is safe inside
2267
- * deferred and immediate transactions alike.
2653
+ * report truncation without a tail COUNT. The returned room set is bounded;
2654
+ * exact unread/directed counts still require scanning every candidate row.
2655
+ * Read-only, so it is safe inside deferred and immediate transactions alike.
2268
2656
  */
2269
2657
  unreadByRoom(agentId, limit, excludeRoomId) {
2270
2658
  const lim = Math.max(1, Math.floor(limit));
@@ -2300,18 +2688,12 @@ export class ChatStore {
2300
2688
  * and advances over lower-priority rows through a disclosed cutoff. Directed
2301
2689
  * rows always qualify so advancing cannot silently erase my_mentions items.
2302
2690
  *
2303
- * unreadSummary: on an EMPTY read, include a bounded
2304
- * rooms_with_unread summary of every OTHER room holding unread, computed in
2305
- * the SAME snapshot as the empty determination -- across two separate
2306
- * queries a message arriving in this room could make the read report
2307
- * "empty" while the summary lists this very room.
2691
+ * unreadSummary: on an EMPTY read, include a bounded rooms_with_unread
2692
+ * summary of every OTHER room holding unread. It is advisory and computed
2693
+ * after the current-room transaction so a large cross-room aggregation does
2694
+ * not hold the process-wide writer reservation.
2308
2695
  */
2309
- catchUp(roomId, agentId, limit, previewChars,
2310
- // Defaulted only because it sits after an optional parameter. 0 is a
2311
- // FAIL-CLOSED sentinel, not "no fencing": personas start at epoch 1 and
2312
- // only ever increment, so an omitted epoch matches nothing and the read is
2313
- // rejected rather than silently running unfenced.
2314
- maxBytes = DEFAULT_MAX_BYTES, epoch = 0, unreadSummary = null) {
2696
+ catchUp(roomId, agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, unreadSummary = null) {
2315
2697
  if (!Number.isSafeInteger(maxBytes) ||
2316
2698
  maxBytes < MIN_CATCH_UP_RESULT_BUDGET ||
2317
2699
  maxBytes > MAX_BULK_RESULT_CHARS) {
@@ -2325,8 +2707,9 @@ export class ChatStore {
2325
2707
  }
2326
2708
  const pageLimit = Math.min(MAX_CATCH_UP_ROWS, Math.max(1, Math.floor(limit)));
2327
2709
  // Advancing path: read the cursor, fetch, and advance inside one IMMEDIATE
2328
- // transaction so a concurrent same-identity call serializes behind it and
2329
- // reads the updated cursor instead of returning overlapping messages.
2710
+ // transaction. A deferred WAL transaction could let another writer commit
2711
+ // after this read snapshot, then fail the cursor update with
2712
+ // SQLITE_BUSY_SNAPSHOT.
2330
2713
  // The byte bound trims BEFORE the advance, so the cursor never covers an
2331
2714
  // undelivered peer row (it may later normalize across own rows, which are
2332
2715
  // never returned): a response the client rejects as oversized can no
@@ -2335,19 +2718,9 @@ export class ChatStore {
2335
2718
  // ADVANCING read: fenced like a mutation, because it moves the read
2336
2719
  // marker. A stale runtime whose catch_up committed would consume messages
2337
2720
  // the current runtime has not seen and can no longer reach.
2338
- this.requireEpoch(agentId, epoch);
2339
- const cursor = this.getCursor(roomId, agentId);
2340
- if (!cursor) {
2341
- // Distinguish a real non-member from a room deleted after the caller
2342
- // resolved it. This extra PK lookup runs only on the error path.
2343
- this.requireRoom(roomId);
2344
- throw new Error("not a member of this room");
2345
- }
2346
- // Consuming messages while absent is invisible consumption: peers see a
2347
- // left persona and cannot tell their traffic is being read. read_history
2348
- // stays open for exactly this reason -- it consumes nothing.
2349
- this.requirePresent(roomId, agentId);
2350
- const from = cursor.last_read_seq;
2721
+ this.requireLive(agentId);
2722
+ // Advancing reads require presence; read_history remains non-advancing.
2723
+ const from = this.requirePresent(roomId, agentId).last_read_seq;
2351
2724
  const priorityOnly = unreadSummary?.priorityOnly === true;
2352
2725
  // Captured under the same IMMEDIATE snapshot as the filtered scan. Own
2353
2726
  // rows count toward the cutoff but never toward skipped/remaining.
@@ -2409,39 +2782,6 @@ export class ChatStore {
2409
2782
  if (lastSeq > from) {
2410
2783
  this.setCursor(roomId, agentId, lastSeq);
2411
2784
  }
2412
- // Empty read: same-snapshot disclosure of where the traffic actually
2413
- // is. Emitted even when no other room has unread ([]): that positively
2414
- // answers "is anything anywhere?", the question an empty read raises.
2415
- const UNREAD_SUMMARY_MAX = 20;
2416
- let summary = null;
2417
- if (unreadSummary !== null && messages.length === 0) {
2418
- const fetched = this.unreadByRoom(agentId, UNREAD_SUMMARY_MAX, roomId);
2419
- // The v0.9 summary was appended after catch_up had already spent the
2420
- // entire page budget. Twenty legal, control-heavy room names could
2421
- // inflate a declared 1k response past 25k. Bound the summary within
2422
- // the same result budget, using the same measured-fit/name-halving
2423
- // pattern as my_mentions.by_room.
2424
- const roomBudget = maxBytes -
2425
- (priorityOnly
2426
- ? PRIORITY_CATCH_UP_SUMMARY_ENVELOPE
2427
- : CATCH_UP_SUMMARY_ENVELOPE);
2428
- const fitted = fitRows(fetched.rooms, roomBudget);
2429
- const rooms = fitted.rows;
2430
- let truncated = fetched.truncated || fitted.sizeTrimmed;
2431
- if (rooms.length === 1 && JSON.stringify(rooms).length > roomBudget) {
2432
- let entry = { ...rooms[0] };
2433
- while (JSON.stringify([entry]).length > roomBudget &&
2434
- entry.name.length > 0) {
2435
- entry = {
2436
- ...entry,
2437
- name: safeCut(entry.name, Math.floor(entry.name.length / 2)),
2438
- };
2439
- }
2440
- rooms[0] = entry;
2441
- truncated = true;
2442
- }
2443
- summary = { rooms, truncated };
2444
- }
2445
2785
  return {
2446
2786
  messages,
2447
2787
  new_last_read_seq: lastSeq,
@@ -2460,11 +2800,36 @@ export class ChatStore {
2460
2800
  // it that a preview/JSON shrink could otherwise hide. `remaining` is the
2461
2801
  // authoritative "more unread" count here, but keep byte_limited honest.
2462
2802
  ...(byteLimited || !exhausted ? { byte_limited: true } : {}),
2463
- ...(summary !== null ? { rooms_with_unread: summary.rooms } : {}),
2464
- ...(summary?.truncated ? { rooms_with_unread_truncated: true } : {}),
2465
2803
  };
2466
2804
  });
2467
- return tx.immediate();
2805
+ const result = tx.immediate();
2806
+ if (unreadSummary === null || result.messages.length > 0)
2807
+ return result;
2808
+ // Keep the potentially large all-room aggregate outside IMMEDIATE. The
2809
+ // summary is routing advice; like any response, it can become stale as soon
2810
+ // as the current-room transaction commits.
2811
+ try {
2812
+ const UNREAD_SUMMARY_MAX = 20;
2813
+ const fetched = this.unreadByRoom(agentId, UNREAD_SUMMARY_MAX, roomId);
2814
+ const roomBudget = maxBytes -
2815
+ (unreadSummary.priorityOnly === true
2816
+ ? PRIORITY_CATCH_UP_SUMMARY_ENVELOPE
2817
+ : CATCH_UP_SUMMARY_ENVELOPE);
2818
+ const fitted = fitRoomSummary(fetched.rooms, roomBudget);
2819
+ return {
2820
+ ...result,
2821
+ rooms_with_unread: fitted.rooms,
2822
+ ...(fetched.truncated || fitted.sizeTrimmed
2823
+ ? { rooms_with_unread_truncated: true }
2824
+ : {}),
2825
+ };
2826
+ }
2827
+ catch {
2828
+ // The cursor may already be committed, including a lossy cutoff, so a
2829
+ // thrown response would invite a retry that cannot reconstruct the old
2830
+ // state. The advisory summary is optional; return the core result.
2831
+ return result;
2832
+ }
2468
2833
  }
2469
2834
  /**
2470
2835
  * Cross-room mentions INBOX: unread messages directed at the agent (its
@@ -2497,6 +2862,9 @@ export class ChatStore {
2497
2862
  // bulk reader with no "more remain" signal when exactly `limit` directed
2498
2863
  // messages fit inside the byte budget: an agent paging on byte_limited
2499
2864
  // (the documented signal) then silently under-read its own inbox.
2865
+ // Keep the candidate predicates in both queries below in sync with
2866
+ // directedAt and idx_messages_directed_candidates. It is redundant for
2867
+ // results but required for SQLite to prove the partial index is eligible.
2500
2868
  const { rows: fetched, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(maxBytes)}, g.id AS gid, g.room_id AS room_id, r.name AS room_name
2501
2869
  FROM ${MESSAGE_FROM}
2502
2870
  JOIN memberships mb ON mb.room_id = g.room_id
@@ -2504,6 +2872,7 @@ export class ChatStore {
2504
2872
  JOIN rooms r ON r.id = g.room_id
2505
2873
  WHERE g.seq > mb.last_read_seq
2506
2874
  AND g.id > ? AND g.agent_id != ?
2875
+ AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
2507
2876
  AND ${directedAt("g")}
2508
2877
  ORDER BY g.id ASC LIMIT ?`), [agentId, afterId, agentId, agentId, agentId, limit + 1], maxBytes);
2509
2878
  const hasExtra = fetched.length > limit;
@@ -2518,6 +2887,7 @@ export class ChatStore {
2518
2887
  AND mb.agent_id = ? AND mb.left_at IS NULL
2519
2888
  WHERE g.seq > mb.last_read_seq
2520
2889
  AND g.agent_id != ?
2890
+ AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
2521
2891
  AND ${directedAt("g")}`)
2522
2892
  .get(agentId, agentId, agentId, agentId);
2523
2893
  const total_directed = td;
@@ -2526,33 +2896,13 @@ export class ChatStore {
2526
2896
  // lives in unreadByRoom, shared with catch_up's rooms_with_unread.
2527
2897
  const BY_ROOM_MAX = 4000;
2528
2898
  const byRoomFetch = this.unreadByRoom(agentId, BY_ROOM_MAX);
2529
- let byRoom = byRoomFetch.rooms;
2530
2899
  // Rooms past BY_ROOM_MAX (the least-directed) were dropped -- flag it.
2531
2900
  const roomLimitHit = byRoomFetch.truncated;
2532
2901
  const roomBudget = Math.floor(maxBytes / 3);
2533
- // Trim off the end (least-directed rooms, already SQL-ordered) with the
2534
- // LINEAR fitRows, not an O(n^2) re-serialize-per-pop loop. It always keeps
2535
- // at least one row, so the single-entry name-halving below still handles a
2536
- // lone oversized room.
2537
- const trimmed = fitRows(byRoom, roomBudget);
2538
- byRoom = trimmed.rows;
2539
- let by_room_truncated = trimmed.sizeTrimmed || roomLimitHit;
2540
- // A single long-named room can still overflow a small budget: halve
2541
- // the display name until the MEASURED serialized size fits (a fixed
2542
- // code-unit cut under-counts JSON escaping, so a control-heavy name
2543
- // slipped past it); room_id remains the stable key.
2544
- if (byRoom.length === 1 && JSON.stringify(byRoom).length > roomBudget) {
2545
- let entry = { ...byRoom[0] };
2546
- while (JSON.stringify([entry]).length > roomBudget &&
2547
- entry.name.length > 0) {
2548
- entry = {
2549
- ...entry,
2550
- name: safeCut(entry.name, Math.floor(entry.name.length / 2)),
2551
- };
2552
- }
2553
- byRoom[0] = entry;
2554
- by_room_truncated = true;
2555
- }
2902
+ // Rows arrive most-directed first, so fitting drops the least-directed.
2903
+ const trimmed = fitRoomSummary(byRoomFetch.rooms, roomBudget);
2904
+ const byRoom = trimmed.rooms;
2905
+ const by_room_truncated = trimmed.sizeTrimmed || roomLimitHit;
2556
2906
  // Joint budget with an exact envelope: messages get what by_room and
2557
2907
  // the fixed response fields leave, floored at the stub allowance
2558
2908
  // (which boundByBytes can always honor; at the schema's 1000-char
@@ -2627,25 +2977,17 @@ export class ChatStore {
2627
2977
  * clamped to [0, latest]. A lower value re-exposes those messages to catch_up.
2628
2978
  * Returns the previous and new marker plus the room's latest seq.
2629
2979
  */
2630
- markRead(roomId, agentId, epoch, seq) {
2980
+ markRead(roomId, agentId, seq) {
2631
2981
  const tx = this.db.transaction(() => {
2632
- this.requireEpoch(agentId, epoch);
2633
- const cursor = this.getCursor(roomId, agentId);
2634
- if (!cursor) {
2635
- // Same ordering rule as claimResource: name a deleted room as deleted
2636
- // rather than reporting its vanished membership as a non-membership.
2637
- this.requireRoom(roomId);
2638
- throw new Error("not a member of this room");
2639
- }
2640
- // Advancing a marker is participation: it consumes messages peers can
2641
- // see you have not read.
2642
- this.requirePresent(roomId, agentId);
2982
+ this.requireLive(agentId);
2983
+ // Advancing a marker requires presence and reports the prior cursor.
2984
+ const previous = this.requirePresent(roomId, agentId).last_read_seq;
2643
2985
  const { latest } = this.db
2644
2986
  .prepare("SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?")
2645
2987
  .get(roomId);
2646
2988
  const target = seq === undefined ? latest : Math.max(0, Math.min(seq, latest));
2647
2989
  this.setCursor(roomId, agentId, target);
2648
- return { previous: cursor.last_read_seq, new: target, latest };
2990
+ return { previous, new: target, latest };
2649
2991
  });
2650
2992
  return tx.immediate();
2651
2993
  }
@@ -2673,7 +3015,6 @@ export class ChatStore {
2673
3015
  const { rows, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(DEFAULT_MAX_BYTES)}
2674
3016
  FROM messages_fts f
2675
3017
  JOIN messages g ON g.id = f.rowid
2676
- LEFT JOIN agents a ON a.id = g.agent_id
2677
3018
  LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
2678
3019
  WHERE f.body MATCH ? AND g.room_id = ?
2679
3020
  ORDER BY rank, g.id LIMIT ? OFFSET ?`), [query, roomId, limit + 1, off], DEFAULT_MAX_BYTES);
@@ -2696,15 +3037,15 @@ export class ChatStore {
2696
3037
  * Trim a room to its newest `keepLast` messages. Only the oldest are removed,
2697
3038
  * so MAX(seq) is unchanged and future seq numbers stay monotonic.
2698
3039
  */
2699
- pruneMessages(roomId, agentId, epoch, keepLast, force) {
3040
+ pruneMessages(roomId, agentId, keepLast, force) {
2700
3041
  // Keep at least the newest message: keepLast=0 would hit OFFSET -1
2701
3042
  // (clamped to 0 by SQLite, silently keeping one row anyway), and deleting
2702
3043
  // ALL rows would reset MAX(seq), breaking the monotonic-seq invariant.
2703
3044
  keepLast = Math.max(1, Math.floor(keepLast));
2704
3045
  const tx = this.db.transaction(() => {
2705
- this.requireEpoch(agentId, epoch);
2706
- // A deleted room must not report a successful no-op prune.
2707
- this.requireRoom(roomId);
3046
+ this.requireLive(agentId);
3047
+ // Pruning requires presence, including when force skips unread checks.
3048
+ this.requirePresent(roomId, agentId);
2708
3049
  const { c: total } = this.db
2709
3050
  .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
2710
3051
  .get(roomId);
@@ -2719,12 +3060,22 @@ export class ChatStore {
2719
3060
  // read position for resume, so their unread is real until they return.
2720
3061
  // (The author has implicitly "seen" its own message, matching catch_up's
2721
3062
  // self-exclusion.) Pass force=true to prune past this.
3063
+ //
3064
+ // RETIRED LLM identities are the one exclusion, and it is required
3065
+ // rather than optional: retirement soft-leaves their memberships, and
3066
+ // a left membership blocks by design because it can come back. A
3067
+ // retired one cannot, so without this join a single model transition
3068
+ // would freeze the room's history permanently behind a cursor no
3069
+ // reader will ever advance. Humans that left stay blockers -- they are
3070
+ // resumable, which is exactly the difference.
2722
3071
  const { u } = this.db
2723
3072
  .prepare(`SELECT COUNT(*) AS u FROM messages g
2724
3073
  WHERE g.room_id = ? AND g.seq < ?
2725
3074
  AND EXISTS (
2726
3075
  SELECT 1 FROM memberships mm
3076
+ JOIN agents aa ON aa.id = mm.agent_id
2727
3077
  WHERE mm.room_id = g.room_id
3078
+ AND aa.retired_at IS NULL
2728
3079
  AND mm.last_read_seq < g.seq AND mm.agent_id != g.agent_id
2729
3080
  )`)
2730
3081
  .get(roomId, cutoff.seq);
@@ -2735,7 +3086,8 @@ export class ChatStore {
2735
3086
  // whom the refusal itself exempts.
2736
3087
  const { m } = this.db
2737
3088
  .prepare(`SELECT MIN(mm.last_read_seq) AS m FROM memberships mm
2738
- WHERE mm.room_id = ? AND EXISTS (
3089
+ JOIN agents aa ON aa.id = mm.agent_id
3090
+ WHERE mm.room_id = ? AND aa.retired_at IS NULL AND EXISTS (
2739
3091
  SELECT 1 FROM messages g WHERE g.room_id = mm.room_id
2740
3092
  AND g.seq < ? AND g.seq > mm.last_read_seq
2741
3093
  AND g.agent_id != mm.agent_id)`)
@@ -2762,12 +3114,12 @@ export class ChatStore {
2762
3114
  /** Hard-delete a room and all of its messages and memberships. Throws a
2763
3115
  * clean "already deleted" error if another process removed it first,
2764
3116
  * instead of reporting a false success with zero counts. */
2765
- deleteRoom(roomId, agentId, epoch) {
3117
+ deleteRoom(roomId, agentId) {
2766
3118
  const tx = this.db.transaction(() => {
2767
3119
  // Fenced for the same reason as createRoom, and more urgently: this is
2768
3120
  // the one irreversible operation in the tool surface. No membership is
2769
3121
  // required (deletion has always been global admin), only a live persona.
2770
- this.requireEpoch(agentId, epoch);
3122
+ this.requireLive(agentId);
2771
3123
  this.requireRoom(roomId);
2772
3124
  const { c: messages } = this.db
2773
3125
  .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
@@ -2793,29 +3145,29 @@ export class ChatStore {
2793
3145
  * winner: the read-check and upsert run in one IMMEDIATE transaction, so two
2794
3146
  * simultaneous claimants cannot both be granted (unlike two "I claim X" chat
2795
3147
  * posts, which can cross). Re-claiming your own key renews the TTL; an
2796
- * expired claim is grantable to anyone. Ownership is per PERSONA, so a claim
2797
- * survives a resume: the runtime that takes the persona over inherits it.
3148
+ * expired claim is grantable to anyone. Ownership is per PERSONA, and a
3149
+ * persona belongs to one connection for life, so a claim has exactly one
3150
+ * holder until it expires, is released, or its holder is retired (retirement
3151
+ * DELETES it rather than transferring it).
2798
3152
  */
2799
- claimResource(roomId, key, agentId, epoch, ttlSeconds, note) {
3153
+ claimResource(roomId, key, agentId, ttlSeconds, note) {
2800
3154
  assertStorable(key, "claim key");
2801
3155
  assertMaxLen(key, "claim key", 500);
2802
3156
  assertStorable(note, "claim note");
2803
3157
  assertMaxLen(note, "claim note", 2000);
3158
+ if (!Number.isSafeInteger(ttlSeconds) ||
3159
+ ttlSeconds < 1 ||
3160
+ ttlSeconds > 86_400) {
3161
+ throw new Error("claim ttl_seconds must be an integer from 1 to 86400 seconds");
3162
+ }
2804
3163
  const tx = this.db.transaction(() => {
2805
- this.requireEpoch(agentId, epoch);
2806
- // Same deleted-room window as postMessage: fail cleanly, not with a
2807
- // raw FK error from the claims INSERT. BEFORE the presence check, because
2808
- // a delete cascades the membership away and "you are not a member" is a
2809
- // false diagnosis of a room that no longer exists.
2810
- this.requireRoom(roomId);
2811
- // Taking (or renewing) a claim asserts coordination inside a room this
2812
- // persona is not in. release_claim stays open: dropping a claim you
2813
- // already hold is cleanup, not participation.
3164
+ this.requireLive(agentId);
3165
+ // Taking a claim requires presence; releasing one remains cleanup.
2814
3166
  this.requirePresent(roomId, agentId);
2815
3167
  const row = this.db
2816
3168
  .prepare(`SELECT agent_id, note,
2817
3169
  strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at,
2818
- (strftime('%s', expires_at) - strftime('%s', 'now')) AS remaining
3170
+ (unixepoch(expires_at) - unixepoch('now')) AS remaining
2819
3171
  FROM claims WHERE room_id = ? AND key = ?`)
2820
3172
  .get(roomId, key);
2821
3173
  if (row && row.remaining > 0 && row.agent_id !== agentId) {
@@ -2849,13 +3201,13 @@ export class ChatStore {
2849
3201
  return tx.immediate();
2850
3202
  }
2851
3203
  /** Release your own claim. Expired claims can be released by anyone. */
2852
- releaseClaim(roomId, key, agentId, epoch) {
3204
+ releaseClaim(roomId, key, agentId) {
2853
3205
  const tx = this.db.transaction(() => {
2854
- this.requireEpoch(agentId, epoch);
3206
+ this.requireLive(agentId);
2855
3207
  this.requireRoom(roomId);
2856
3208
  const row = this.db
2857
3209
  .prepare(`SELECT agent_id,
2858
- (strftime('%s', expires_at) - strftime('%s', 'now')) AS remaining
3210
+ (unixepoch(expires_at) - unixepoch('now')) AS remaining
2859
3211
  FROM claims WHERE room_id = ? AND key = ?`)
2860
3212
  .get(roomId, key);
2861
3213
  if (!row)
@@ -2897,7 +3249,7 @@ export class ChatStore {
2897
3249
  substr(note, 1, ${PREVIEW}) AS note,
2898
3250
  CASE WHEN length(note) > ${PREVIEW} THEN 1 ELSE 0 END AS note_cut,
2899
3251
  strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at,
2900
- (strftime('%s', expires_at) - strftime('%s', 'now')) AS expires_in_seconds
3252
+ (unixepoch(expires_at) - unixepoch('now')) AS expires_in_seconds
2901
3253
  FROM claims WHERE room_id = ? AND key > ? ORDER BY key LIMIT ?`)
2902
3254
  .all(roomId, afterKey, lim + 1);
2903
3255
  const { c: total } = this.db