multi-agent-collaboration-mcp 0.12.0 → 0.13.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
@@ -39,6 +39,10 @@ const MAX_GET_MESSAGE_CHARS = 400_000;
39
39
  /** Caller-supplied post idempotency keys are opaque, room/author scoped, and
40
40
  * intentionally small enough to keep the sparse unique index cheap. */
41
41
  export const MAX_CLIENT_MESSAGE_ID_CHARS = 200;
42
+ /** Persona/human id cap. Exported because the server SIZES the canonical id it
43
+ * generates against it before allocating, rather than discovering the overflow
44
+ * as a store assertion after the fact. */
45
+ export const MAX_AGENT_ID_CHARS = 200;
42
46
  /** Above this body length the store's json well-formedness re-validation is
43
47
  * skipped: parsing a ~GB body into memory to walk it would defeat the
44
48
  * memory-bounded read design, and such a caller owns validation (the MCP
@@ -144,10 +148,64 @@ const STUB_ALLOWANCE = 500;
144
148
  * remains it must reject the call rather than advance past an undeliverable
145
149
  * page. */
146
150
  export const MIN_CATCH_UP_RESULT_BUDGET = Math.max(CATCH_UP_ENVELOPE, PRIORITY_CATCH_UP_ENVELOPE) + STUB_ALLOWANCE;
147
- /** Age (SQLite datetime modifier) past which a silent private session cursor
148
- * is dead: reaped by the join-time GC and by prune (a dead cursor must not
149
- * block retention forever). Live sessions refresh on every join/touch. */
150
- const SESSION_GC_AGE = "-7 days";
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
+ export class PersonaLostError extends Error {
159
+ 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`);
168
+ this.name = "PersonaLostError";
169
+ this.agentId = agentId;
170
+ this.expectedEpoch = expectedEpoch;
171
+ this.currentEpoch = currentEpoch;
172
+ }
173
+ }
174
+ /**
175
+ * A CORRECT resume word presented with a different brand/model/version.
176
+ *
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.
184
+ *
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.
188
+ */
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;
207
+ }
208
+ }
151
209
  /** Serialized-size budget for a metadata listing's row ARRAY, leaving room for
152
210
  * the response envelope (total, truncated/size_trimmed flags) AND the keyset
153
211
  * paging cursor, so the WHOLE response stays under DEFAULT_MAX_BYTES. The
@@ -225,6 +283,18 @@ function assertMaxLen(value, field, max) {
225
283
  throw new Error(`${field} exceeds ${max} characters`);
226
284
  }
227
285
  }
286
+ /**
287
+ * NULL is the only way to say "no role". An empty or whitespace-only string is
288
+ * a third state that renders as no role while still being a set value, so the
289
+ * membership row would report a role that no listing can show and no filter can
290
+ * match. Rejected at the store, not just at the tool schema, so a direct-store
291
+ * caller cannot create the state either.
292
+ */
293
+ function assertRole(role) {
294
+ if (role !== null && role.trim() === "") {
295
+ throw new Error("role must be a non-blank string, or null to clear it");
296
+ }
297
+ }
228
298
  /**
229
299
  * Trim a metadata listing to a serialized-size budget, dropping WHOLE rows off
230
300
  * the end (still reachable via offset paging). The per-row preview caps bound
@@ -282,7 +352,7 @@ function messageCols(bodyCap) {
282
352
  // (which pages in codepoints) and the bulk reads used to disagree by the
283
353
  // astral factor (an emoji is 1 codepoint but 2 UTF-16 units). NUL is
284
354
  // rejected/sanitized, so length() is exact.
285
- return `g.seq, g.agent_id, a.type AS from_type, a.role AS from_role,
355
+ return `g.seq, g.agent_id,
286
356
  g.format, g.priority, substr(g.body, 1, ${cap}) AS body, g.body_len,
287
357
  length(g.body) AS body_cp,
288
358
  g.mentions, g.reply_to_seq,
@@ -294,8 +364,11 @@ function messageCols(bodyCap) {
294
364
  ORDER BY s.seq DESC LIMIT 1) AS superseded_by,
295
365
  p.agent_id AS reply_from, substr(p.body, 1, 101) AS reply_preview`;
296
366
  }
367
+ // No join to agents: a message envelope carries the author id and nothing else
368
+ // about the author. Role is room-local and mutable, so stamping it into a read
369
+ // of an old message reported that message as written in a role its author may
370
+ // never have held when writing it.
297
371
  const MESSAGE_FROM = `messages g
298
- LEFT JOIN agents a ON a.id = g.agent_id
299
372
  LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq`;
300
373
  /**
301
374
  * SQL predicate for "directed at me": an explicit mention, OR a reply to a
@@ -347,14 +420,15 @@ export class ChatStore {
347
420
  }
348
421
  catch { }
349
422
  }
350
- // Converting a legacy rollback-journal file to WAL needs an exclusive
423
+ // Switching a brand-new rollback-journal file to WAL needs an exclusive
351
424
  // lock, and SQLite can return SQLITE_BUSY here WITHOUT consulting the
352
425
  // busy handler (better-sqlite3's default 5s timeout does not cover this
353
- // path), so two fresh processes racing to convert the same legacy file
426
+ // path), so two fresh processes racing to convert the same file
354
427
  // intermittently crashed on startup (reproduced ~1 in 24 synchronized
355
- // opens). Retry with a short synchronous backoff: the loser of the race
356
- // finds the file already in WAL and succeeds immediately. A no-op on
357
- // already-WAL files, i.e. every startup after the first.
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.
358
432
  for (let attempt = 1;; attempt++) {
359
433
  try {
360
434
  this.db.pragma("journal_mode = WAL");
@@ -375,14 +449,14 @@ export class ChatStore {
375
449
  }
376
450
  this.db.pragma("busy_timeout = 5000");
377
451
  this.db.pragma("foreign_keys = ON");
378
- // One process performs versioned data maintenance at a time. Without an
379
- // IMMEDIATE transaction, concurrent MCP startups could all observe the
380
- // old version and then repeat full-corpus backfills/FTS rebuilds.
381
- this.db.transaction(() => this.migrate()).immediate();
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.
455
+ this.db.transaction(() => this.initializeSchema()).immediate();
382
456
  }
383
457
  catch (error) {
384
458
  // A constructor that throws has no caller-visible instance on which to
385
- // call close(); cover pragma/setup failures as well as migration errors.
459
+ // call close(); cover pragma/setup failures as well as schema errors.
386
460
  try {
387
461
  this.db.close();
388
462
  }
@@ -390,7 +464,13 @@ export class ChatStore {
390
464
  throw error;
391
465
  }
392
466
  }
393
- migrate() {
467
+ /**
468
+ * Create the current schema. NOT a migration: there is exactly one schema,
469
+ * every statement is CREATE ... IF NOT EXISTS, and a database written by an
470
+ * older build is neither detected nor upgraded -- its queries fail raw.
471
+ * Replacing the file is a deployment step.
472
+ */
473
+ initializeSchema() {
394
474
  this.db.exec(`
395
475
  CREATE TABLE IF NOT EXISTS rooms (
396
476
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -400,14 +480,54 @@ export class ChatStore {
400
480
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
401
481
  );
402
482
 
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.
403
490
  CREATE TABLE IF NOT EXISTS agents (
404
- id TEXT PRIMARY KEY,
405
- type TEXT,
406
- role TEXT,
407
- description TEXT,
408
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
491
+ id TEXT PRIMARY KEY,
492
+ is_human INTEGER NOT NULL DEFAULT 0,
493
+ brand TEXT,
494
+ model TEXT,
495
+ 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,
504
+ description TEXT,
505
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
506
+ CHECK (
507
+ (is_human = 1
508
+ AND brand IS NULL AND model IS NULL
509
+ AND version IS NULL AND resume_word IS NULL)
510
+ OR
511
+ (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)
514
+ )
409
515
  );
410
516
 
517
+ -- role is ROOM-LOCAL and nullable: one persona can be reviewer in one
518
+ -- room and writer in another, and "no role" is a real state distinct from
519
+ -- an empty string. It is deliberately NOT on agents, where a single value
520
+ -- had to be true everywhere at once.
521
+ --
522
+ -- last_seen means RECENT RUNTIME ACTIVITY, not model activity. Two things
523
+ -- set it: an MCP call from the bound runtime, and the two-minute heartbeat
524
+ -- of a generated watcher armed by wait_for_messages. So a fresh last_seen
525
+ -- proves a runtime exists and is reachable; it does NOT prove the model is
526
+ -- reasoning, reading, or able to wake. An armed seat therefore reads as
527
+ -- recently active for as long as its watcher lives, which is the point:
528
+ -- the alternative was a seat that looked offline every time the model went
529
+ -- quiet between turns. Code that needs the stronger claim wants
530
+ -- wait_leases ("watching"), which is an open blocking call.
411
531
  CREATE TABLE IF NOT EXISTS memberships (
412
532
  room_id INTEGER NOT NULL REFERENCES rooms(id),
413
533
  agent_id TEXT NOT NULL REFERENCES agents(id),
@@ -415,6 +535,7 @@ export class ChatStore {
415
535
  last_read_seq INTEGER NOT NULL DEFAULT 0,
416
536
  last_seen TEXT,
417
537
  left_at TEXT,
538
+ role TEXT,
418
539
  PRIMARY KEY (room_id, agent_id)
419
540
  );
420
541
 
@@ -426,65 +547,40 @@ export class ChatStore {
426
547
  format TEXT NOT NULL DEFAULT 'text',
427
548
  priority INTEGER NOT NULL DEFAULT 0,
428
549
  body TEXT NOT NULL,
550
+ -- The exact UTF-16 length the web viewer reports, stamped by every
551
+ -- insert (SQLite length() under-counts astral text). NOT NULL: both
552
+ -- supported writers stamp it, so read paths carry no fallback and a
553
+ -- direct SQL writer that omits it is rejected rather than producing a
554
+ -- row whose reported length is silently wrong for astral text.
555
+ body_len INTEGER NOT NULL,
429
556
  mentions TEXT,
430
557
  reply_to_seq INTEGER,
558
+ -- Denormalized reply author, so my_mentions finds a reply whose parent
559
+ -- was later pruned.
560
+ reply_to_agent TEXT,
561
+ supersedes_seq INTEGER,
431
562
  client_message_id TEXT,
432
563
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
433
564
  UNIQUE (room_id, seq)
434
565
  );
435
566
  `);
436
- // Upgrade older database files that predate these columns. Run before
437
- // creating any column-dependent index so the index never precedes its
438
- // column on a legacy schema.
439
- this.ensureColumn("rooms", "pinned", "TEXT");
440
- this.ensureColumn("memberships", "last_seen", "TEXT");
441
- this.ensureColumn("memberships", "left_at", "TEXT");
442
- this.ensureColumn("messages", "format", "TEXT NOT NULL DEFAULT 'text'");
443
- this.ensureColumn("messages", "priority", "INTEGER NOT NULL DEFAULT 0");
444
- this.ensureColumn("messages", "reply_to_seq", "INTEGER");
445
- this.ensureColumn("messages", "mentions", "TEXT");
446
- this.ensureColumn("messages", "supersedes_seq", "INTEGER");
447
- this.ensureColumn("messages", "reply_to_agent", "TEXT");
448
- this.ensureColumn("messages", "body_len", "INTEGER");
449
- this.ensureColumn("messages", "client_message_id", "TEXT");
450
- // DB-level enforcement for MIXED-VERSION windows: a still-running old
451
- // build inserts without these columns, and if the reply's parent is
452
- // pruned before any new-build process restarts, the startup backfill
453
- // below can never recover the author -- the reply is silently
454
- // undirected forever. Triggers live in the database file, so they fire
455
- // for the old build's inserts too. New builds stamp both columns
456
- // explicitly, so the WHEN clauses skip their rows.
567
+ // Reject an embedded NUL at the DATABASE level: SQLite's substr()/length()
568
+ // stop at U+0000, so a NUL body reads back truncated with the read marker
569
+ // advancing past the lost tail. This file's writers already reject it in JS
570
+ // (assertStorable), but the web server holds its own handle and tests open
571
+ // the file directly; a trigger lives in the database and therefore covers
572
+ // every writer, which is the same reason the agents CHECK above is in SQL.
573
+ // (A trigger cannot SANITIZE the value -- replace() is itself
574
+ // NUL-terminated -- so it aborts instead.)
457
575
  this.db.exec(`
458
- CREATE TRIGGER IF NOT EXISTS messages_reply_agent_ai AFTER INSERT ON messages
459
- WHEN NEW.reply_to_seq IS NOT NULL AND NEW.reply_to_agent IS NULL BEGIN
460
- UPDATE messages SET reply_to_agent =
461
- (SELECT p.agent_id FROM messages p
462
- WHERE p.room_id = NEW.room_id AND p.seq = NEW.reply_to_seq)
463
- WHERE id = NEW.id;
464
- END;
465
-
466
- -- Routine current-build inserts provide body_len and occupy no entry.
467
- -- This makes the recurring mixed-version repair an empty-index probe on a
468
- -- healthy file instead of a corpus scan.
469
- CREATE INDEX IF NOT EXISTS idx_messages_body_len_missing
470
- ON messages(id) WHERE body_len IS NULL;
471
-
472
- -- Reject an embedded NUL at the DATABASE level: SQLite's substr()/length()
473
- -- stop at U+0000, so a NUL body reads back truncated with the marker
474
- -- advancing past the lost tail. New builds already reject it in JS
475
- -- (assertStorable), but an OLD build writing during a rolling upgrade
476
- -- would not -- this trigger fires for its inserts too and aborts them,
477
- -- closing that window. (A SQL trigger cannot SANITIZE the value: replace()
478
- -- is itself NUL-terminated. Existing NUL rows are healed in JS below.)
479
576
  CREATE TRIGGER IF NOT EXISTS messages_reject_nul BEFORE INSERT ON messages
480
577
  WHEN instr(NEW.body, char(0)) > 0 BEGIN
481
578
  SELECT RAISE(ABORT, 'message body contains a NUL character (U+0000), which SQLite cannot store without silently truncating');
482
579
  END;
483
580
 
484
- -- Metadata listings also use SQLite substr()/length(), so old/direct
485
- -- writers must not be able to introduce a new silently truncated value.
486
- -- UPDATE guards are per-column: a v3 heal can repair two malformed room
487
- -- fields one at a time without the other field blocking it.
581
+ -- Metadata listings also use SQLite substr()/length(), so no writer may
582
+ -- introduce a silently truncated value. UPDATE guards are per-column so
583
+ -- one malformed room field never blocks repairing the other.
488
584
  CREATE TRIGGER IF NOT EXISTS rooms_reject_nul_insert BEFORE INSERT ON rooms
489
585
  WHEN instr(NEW.description, char(0)) > 0 OR instr(NEW.pinned, char(0)) > 0 BEGIN
490
586
  SELECT RAISE(ABORT, 'room metadata contains a NUL character (U+0000)');
@@ -509,126 +605,10 @@ export class ChatStore {
509
605
  SELECT RAISE(ABORT, 'agent description contains a NUL character (U+0000)');
510
606
  END;
511
607
  `);
512
- // The former messages_body_len_ai trigger stamped SQLite length(NEW.body),
513
- // which under-counts astral text versus the web viewer's UTF-16 body_len.
514
- // Keep a harmless blocker UNDER THE LEGACY NAME so a restarted old build's
515
- // CREATE TRIGGER IF NOT EXISTS cannot resurrect that stamper. Inspect the
516
- // stored definition first: unconditional DROP/CREATE caused schema churn,
517
- // WAL writes, and an exclusive schema lock on every healthy MCP startup.
518
- const bodyLenBlockerMarker = "agent-chat-body-len-blocker-v3";
519
- const existingBodyLenTrigger = this.db
520
- .prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'messages_body_len_ai'")
521
- .get();
522
- if (!existingBodyLenTrigger?.sql?.includes(bodyLenBlockerMarker)) {
523
- this.db.exec(`
524
- DROP TRIGGER IF EXISTS messages_body_len_ai;
525
- CREATE TRIGGER messages_body_len_ai AFTER INSERT ON messages
526
- WHEN NEW.body_len IS NULL BEGIN
527
- SELECT '${bodyLenBlockerMarker}';
528
- END;
529
- `);
530
- }
531
- const SCHEMA_VERSION = 3;
532
- const currentVersion = this.db.pragma("user_version", {
533
- simple: true,
534
- });
535
- // Keep version gates narrow. Advancing v2 -> v3 for bounded metadata repair
536
- // must not repeat the older full-message maintenance pass.
537
- const needsV2Maintenance = currentVersion < 2;
538
- const needsMetadataV3 = currentVersion < 3;
539
- const needsBodyNulHeal = currentVersion < 1;
540
- // Backfill the denormalized reply author AFTER the old-writer trigger above
541
- // exists, closing a rolling-upgrade gap: with the backfill running FIRST, an
542
- // old build inserting a reply in the window between the two steps hit
543
- // neither (backfill already passed, trigger not yet created), and if the
544
- // parent was pruned before any restart the author was unrecoverable and
545
- // my_mentions missed the reply forever. Now a reply inserted before the
546
- // trigger is caught here; one inserted after is stamped by the trigger.
547
- // Rows whose parent is already gone stay NULL (unrecoverable) and are
548
- // re-examined harmlessly.
549
- if (needsV2Maintenance) {
550
- this.db.exec(`
551
- UPDATE messages SET reply_to_agent =
552
- (SELECT p.agent_id FROM messages p
553
- WHERE p.room_id = messages.room_id AND p.seq = messages.reply_to_seq)
554
- WHERE reply_to_seq IS NOT NULL AND reply_to_agent IS NULL
555
- `);
556
- }
557
- // The message-body NUL scan below runs ONLY until this file is marked
558
- // migrated via PRAGMA user_version. It reads EVERY body to evaluate
559
- // instr(body, char(0)) -- costly on a large corpus (a 1 GB body is read
560
- // every startup) and pointless once done: the reject trigger blocks any new
561
- // NUL row, so a migrated file cannot acquire one. The gate is set at the END
562
- // of migrate(), so a crash mid-scan just re-runs. Everything else
563
- // Schema/index/trigger creation and the sparse body_len repair stay
564
- // recurring; each later migration has its own narrowly-scoped gate.
565
- // Heal existing rows that already hold a NUL (written by a pre-guard build):
566
- // a plain SELECT is NOT NUL-terminated, so the full body is recoverable;
567
- // replace each NUL with U+FFFD so substr()/length() readers see it whole,
568
- // and clear body_len so the backfill below re-stamps the corrected length.
569
- // Cursored ONE ROW AT A TIME (not .all(), which materialized every
570
- // malformed body at once and could OOM the process at startup on many/large
571
- // NUL bodies), via a regex replace (not split/join, which builds a giant
572
- // array on an all-NUL body). instr() detects them; healing a row clears its
573
- // NUL, so the predicate never revisits it and the id cursor moves strictly
574
- // forward.
575
- let healedMessageBody = false;
576
- if (needsBodyNulHeal) {
577
- const nextNul = this.db.prepare(`SELECT id, length(CAST(body AS BLOB)) AS bytes
578
- FROM messages
579
- WHERE instr(body, char(0)) > 0 AND id > ?
580
- ORDER BY id LIMIT 1`);
581
- const getBody = this.db.prepare("SELECT body FROM messages WHERE id = ?");
582
- const fix = this.db.prepare("UPDATE messages SET body = ?, body_len = NULL WHERE id = ?");
583
- let cursor = 0;
584
- for (;;) {
585
- const row = nextNul.get(cursor);
586
- if (!row)
587
- break;
588
- if (row.bytes > MAX_MESSAGE_BODY_BYTES) {
589
- throw new Error(`legacy message ${row.id} is ${row.bytes} bytes and contains NUL; ` +
590
- `refusing to load it above the ${MAX_MESSAGE_BODY_BYTES}-byte safety limit`);
591
- }
592
- const { body } = getBody.get(row.id);
593
- fix.run(body.replace(/\u0000/g, "\ufffd"), row.id);
594
- healedMessageBody = true;
595
- cursor = row.id;
596
- }
597
- }
598
- // Backfill body_len (the exact UTF-16 length the WEB viewer reports) for
599
- // rows that predate the column. It must be measured in JS, so we LOAD the
600
- // body -- but only for rows small enough to be safe (length() is a cheap
601
- // codepoint gate, an upper bound on UTF-16 units is 2x that, so <=
602
- // BACKFILL_MAX_CHARS codepoints is at most ~2x that many UTF-16 units held
603
- // at once). Giant legacy rows are stamped with the memory-safe codepoint
604
- // count via SQL (a low-but-nonzero bound; the web viewer's COALESCE still
605
- // shows a length and its total>shown guard tolerates the codepoint skew).
606
- // Cursored by id so the whole sweep is one table pass, one body resident.
607
- // Always repair the sparse set of mixed-version NULL rows. On a healthy
608
- // file idx_messages_body_len_missing is empty, so this is O(1); it avoids a
609
- // full startup scan while keeping the documented next-reopen guarantee.
610
- const BACKFILL_MAX_CHARS = 1_000_000;
611
- this.db
612
- .prepare(`UPDATE messages INDEXED BY idx_messages_body_len_missing
613
- SET body_len = length(body)
614
- WHERE body_len IS NULL AND length(body) > ?`)
615
- .run(BACKFILL_MAX_CHARS);
616
- const nextMissingBodyLen = this.db.prepare(`SELECT id, body FROM messages INDEXED BY idx_messages_body_len_missing
617
- WHERE body_len IS NULL AND id > ? ORDER BY id LIMIT 1`);
618
- const setBodyLen = this.db.prepare("UPDATE messages SET body_len = ? WHERE id = ?");
619
- let bodyLenCursor = 0;
620
- for (;;) {
621
- const row = nextMissingBodyLen.get(bodyLenCursor);
622
- if (!row)
623
- break;
624
- setBodyLen.run(row.body.length, row.id);
625
- bodyLenCursor = row.id;
626
- }
627
608
  this.db.exec(`
628
- -- UNIQUE(room_id, seq) already provides an implicit (room_id, seq) index
629
- -- (sqlite_autoindex, same query plans); an explicit duplicate only taxes
630
- -- every insert. Drop it from database files created by older builds.
631
- DROP INDEX IF EXISTS idx_messages_room_seq;
609
+ -- No explicit (room_id, seq) index: UNIQUE(room_id, seq) already provides
610
+ -- one (sqlite_autoindex, same query plans), and a duplicate only taxes
611
+ -- every insert.
632
612
  CREATE INDEX IF NOT EXISTS idx_messages_reply ON messages(room_id, reply_to_seq);
633
613
  CREATE INDEX IF NOT EXISTS idx_messages_supersedes ON messages(room_id, supersedes_seq);
634
614
  -- Routine posts store NULL and therefore occupy no entry in this sparse
@@ -649,51 +629,6 @@ export class ChatStore {
649
629
  CREATE INDEX IF NOT EXISTS idx_memberships_agent_present
650
630
  ON memberships(agent_id, left_at, room_id);
651
631
 
652
- -- Per-session read CURSORS for identities running multiple concurrent
653
- -- sessions (join_room cursor:'private'). The memberships marker stays the
654
- -- identity-level read receipt (advanced to the MAX across sessions). The
655
- -- left_at column here is VESTIGIAL and no longer read: presence moved to
656
- -- the session_presence table below, which every session (shared too)
657
- -- registers in, so a leave can no longer evict a live twin.
658
- CREATE TABLE IF NOT EXISTS session_markers (
659
- room_id INTEGER NOT NULL REFERENCES rooms(id),
660
- agent_id TEXT NOT NULL REFERENCES agents(id),
661
- session_id TEXT NOT NULL,
662
- last_read_seq INTEGER NOT NULL DEFAULT 0,
663
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
664
- left_at TEXT,
665
- PRIMARY KEY (room_id, agent_id, session_id)
666
- );
667
- -- touch()/touchSessionMarkers refresh a session's rows by session_id
668
- -- ALONE (a process-unique nonce, keyed independently of room/agent). The
669
- -- PK starts with room_id, so that predicate had no usable index and did a
670
- -- full session_markers scan on every liveness touch; this covers it.
671
- CREATE INDEX IF NOT EXISTS idx_session_markers_session
672
- ON session_markers(session_id);
673
- CREATE INDEX IF NOT EXISTS idx_session_markers_room_updated
674
- ON session_markers(room_id, updated_at);
675
-
676
- -- Per-session PRESENCE, decoupled from cursors: EVERY session (shared or
677
- -- private) registers a row here on join, keyed by its process nonce, so a
678
- -- leave can tell whether any OTHER session of the identity is still here.
679
- -- memberships.left_at is recomputed from this table as "present iff any
680
- -- row is live (left_at IS NULL and refreshed within the GC window)", which
681
- -- keeps the cross-process poller (it reads memberships.left_at) working
682
- -- unchanged. Separate from session_markers so it never perturbs cursor
683
- -- semantics (a shared session has a presence row but no cursor row).
684
- CREATE TABLE IF NOT EXISTS session_presence (
685
- room_id INTEGER NOT NULL REFERENCES rooms(id),
686
- agent_id TEXT NOT NULL REFERENCES agents(id),
687
- session_id TEXT NOT NULL,
688
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
689
- left_at TEXT,
690
- PRIMARY KEY (room_id, agent_id, session_id)
691
- );
692
- CREATE INDEX IF NOT EXISTS idx_session_presence_session
693
- ON session_presence(session_id);
694
- CREATE INDEX IF NOT EXISTS idx_session_presence_room_updated
695
- ON session_presence(room_id, updated_at);
696
-
697
632
  -- Advisory single-winner work claims with TTL. Purely advisory: nothing
698
633
  -- fences the claimed resource itself; expiry frees claims from crashed
699
634
  -- holders.
@@ -707,29 +642,35 @@ export class ChatStore {
707
642
  PRIMARY KEY (room_id, key)
708
643
  );
709
644
 
710
- -- In-turn wait leases: a row exists ONLY while an agent's blocking
645
+ -- In-turn wait leases: a row exists ONLY while a persona's blocking
711
646
  -- catch_up wait is open in that room, making "actively watching" a
712
647
  -- verifiable server state (recipientStatus/list_agents expose it as
713
648
  -- "watching"). expires_at is the wait deadline plus a small grace, so a
714
649
  -- crashed waiter's row self-expires; the handler deletes it on every
715
- -- normal or aborted exit. Detached pollers never write here (their
716
- -- probe is query_only), which is deliberate: shell liveness must not
717
- -- read as model availability.
650
+ -- normal or aborted exit. Detached pollers never write here, which is
651
+ -- deliberate: an armed watcher is a shell process, and "watching" claims
652
+ -- an OPEN BLOCKING CALL. A generated watcher does refresh last_seen (see
653
+ -- memberships), so last_seen is the weaker signal of the two: recent
654
+ -- runtime activity. "watching" stays the strong one.
655
+ --
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.
718
663
  CREATE TABLE IF NOT EXISTS wait_leases (
719
664
  room_id INTEGER NOT NULL REFERENCES rooms(id),
720
665
  agent_id TEXT NOT NULL REFERENCES agents(id),
721
- session_id TEXT NOT NULL,
666
+ epoch INTEGER NOT NULL,
722
667
  started_at TEXT NOT NULL DEFAULT (datetime('now')),
723
668
  expires_at TEXT NOT NULL,
724
- PRIMARY KEY (room_id, agent_id, session_id)
669
+ PRIMARY KEY (room_id, agent_id)
725
670
  );
726
671
  `);
727
- // Add session_markers.left_at to database files created before presence
728
- // became session-aware (the CREATE TABLE above already has it for fresh
729
- // files). Runs after the table exists, so ensureColumn's ALTER is valid.
730
- this.ensureColumn("session_markers", "left_at", "TEXT");
731
- // claims is created in the block above, later than rooms/agents, so install
732
- // its old/direct-writer NUL guards here before the one-time v3 heal.
672
+ // claims is created in the block above, later than rooms/agents, so its
673
+ // NUL guards install here, after the table exists.
733
674
  this.db.exec(`
734
675
  CREATE TRIGGER IF NOT EXISTS claims_reject_nul_insert BEFORE INSERT ON claims
735
676
  WHEN instr(NEW.note, char(0)) > 0 BEGIN
@@ -741,18 +682,6 @@ export class ChatStore {
741
682
  SELECT RAISE(ABORT, 'claim note contains a NUL character (U+0000)');
742
683
  END;
743
684
  `);
744
- // Heal legacy embedded NULs in metadata columns too (message bodies are
745
- // healed above, with their body_len reset). Listing SQL uses substr(), which
746
- // stops at the first NUL, so a legacy NUL silently truncated the shown
747
- // value. Runs after every table exists; new writes are already rejected.
748
- // Run once with the versioned maintenance pass. Even small full-table
749
- // scans become expensive when multiplied across many MCP processes.
750
- if (needsMetadataV3) {
751
- this.healNulColumn("rooms", "description");
752
- this.healNulColumn("rooms", "pinned");
753
- this.healNulColumn("agents", "description");
754
- this.healNulColumn("claims", "note");
755
- }
756
685
  // Full-text search over message bodies. External-content FTS5 mirrors
757
686
  // messages.body keyed by messages.id; triggers keep it in sync.
758
687
  this.db.exec(`
@@ -767,112 +696,73 @@ export class ChatStore {
767
696
  VALUES ('delete', old.id, old.body);
768
697
  END;
769
698
  `);
770
- // Backfill decision by INDEX CONSISTENCY, not table existence: a process
771
- // dying between the CREATE above and the rebuild below used to leave a
772
- // database where every later start saw the table and skipped the rebuild
773
- // forever -- all pre-FTS messages permanently invisible to search. A row
774
- // A steady-state empty/nonempty mismatch repairs the crash window without
775
- // a corpus scan; the pre-v2 migration additionally performs exact counts.
776
- // The index row count MUST come from the messages_fts_docsize shadow
777
- // table: with external content, COUNT(*) on the virtual table itself
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
778
706
  // reads the content table and always matches. BOTH counts are read in ONE
779
707
  // statement (a single consistent snapshot): two separate SELECTs could
780
708
  // straddle a concurrent insert -- messages counted pre-insert, fts counted
781
709
  // post-trigger -- making an already-inconsistent {2,1} file read as {2,2}
782
- // and skip the rebuild it actually needed. A legacy NUL repair changes a
783
- // body before these triggers exist; row counts still match in that case,
784
- // so the repair flag must force a rebuild to replace stale tokens.
710
+ // and skip the rebuild it actually needed.
785
711
  const { hasMessages, hasFtsRows } = this.db
786
712
  .prepare(`SELECT EXISTS(SELECT 1 FROM messages LIMIT 1) AS hasMessages,
787
713
  EXISTS(SELECT 1 FROM messages_fts_docsize LIMIT 1) AS hasFtsRows`)
788
714
  .get();
789
- let rebuildFts = healedMessageBody || hasMessages !== hasFtsRows;
790
- // The steady-state sentinel above catches the historical empty-index crash
791
- // class in O(1). A full consistency count remains appropriate during the
792
- // one-time pre-v2 migration, but not on every MCP process startup.
793
- if (!rebuildFts && needsV2Maintenance) {
794
- const { msgCount, ftsCount } = this.db
795
- .prepare(`SELECT (SELECT COUNT(*) FROM messages) AS msgCount,
796
- (SELECT COUNT(*) FROM messages_fts_docsize) AS ftsCount`)
797
- .get();
798
- rebuildFts = ftsCount !== msgCount;
799
- }
800
- if (rebuildFts) {
715
+ if (hasMessages !== hasFtsRows) {
801
716
  this.db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
802
717
  }
803
- // Mark this file migrated so the message-body NUL scan above is skipped on
804
- // future startups (one-time work; the reject trigger keeps new rows clean).
805
- // Set LAST, only after the scan ran, so a crash mid-scan leaves user_version
806
- // unchanged and it re-runs.
807
- if (currentVersion < SCHEMA_VERSION) {
808
- this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
809
- }
810
- }
811
- ensureColumn(table, column, type) {
812
- const cols = this.db
813
- .prepare(`PRAGMA table_info(${table})`)
814
- .all();
815
- if (!cols.some((c) => c.name === column)) {
816
- try {
817
- this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
818
- }
819
- catch (e) {
820
- // Two fresh processes migrating the same legacy file can both pass
821
- // the PRAGMA check; the loser's ALTER must be a no-op, not a startup
822
- // crash.
823
- const msg = e instanceof Error ? e.message : String(e);
824
- if (!/duplicate column name/i.test(msg))
825
- throw e;
826
- }
827
- }
828
718
  }
719
+ // --- rooms -------------------------------------------------------------
829
720
  /**
830
- * Heal legacy embedded NULs in a bounded TEXT metadata column (room
831
- * description/pinned, agent description, claim note): replace U+0000 with
832
- * U+FFFD so a listing's substr()/length() reads the value whole instead of
833
- * truncating at the NUL. Cursored by rowid one row at a time (these columns
834
- * cap at <=10k and NUL rows are exotic); a plain SELECT is not NUL-terminated,
835
- * so the value is recoverable. `table`/`col` are fixed internal identifiers,
836
- * never caller input. Healing clears the NUL, so the predicate never revisits
837
- * a row and the rowid cursor moves strictly forward.
721
+ * Create a room, epoch-fenced to the calling persona.
722
+ *
723
+ * Room administration stays UNAUTHENTICATED and global: any current persona
724
+ * can create or delete any room, and nothing verifies who they are. What the
725
+ * fence removes is authority from a runtime the system positively knows is
726
+ * superseded. Leaving the highest-blast-radius operations as the only
727
+ * unfenced writes made the epoch model exempt exactly what it should protect
728
+ * most: a fenced-out runtime could not post a message but could delete the
729
+ * room the message was in.
730
+ *
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.
838
734
  */
839
- healNulColumn(table, col) {
840
- const next = this.db.prepare(`SELECT rowid AS rid, ${col} AS v FROM ${table}
841
- WHERE instr(${col}, char(0)) > 0 AND rowid > ? ORDER BY rowid LIMIT 1`);
842
- const fix = this.db.prepare(`UPDATE ${table} SET ${col} = ? WHERE rowid = ?`);
843
- let cursor = 0;
844
- for (;;) {
845
- const row = next.get(cursor);
846
- if (!row)
847
- break;
848
- fix.run(row.v.replace(/\u0000/g, "\ufffd"), row.rid);
849
- cursor = row.rid;
850
- }
851
- }
852
- // --- rooms -------------------------------------------------------------
853
- createRoom(name, description, pinned) {
735
+ createRoom(name, description, pinned, agentId, epoch) {
854
736
  assertStorable(name, "room name");
855
737
  assertMaxLen(name, "room name", 200);
856
738
  assertStorable(description, "room description");
857
739
  assertMaxLen(description, "room description", 2000);
858
740
  assertStorable(pinned, "room pinned intro");
859
741
  assertMaxLen(pinned, "room pinned intro", 10_000);
860
- return this.db
861
- .prepare(`INSERT INTO rooms (name, description, pinned) VALUES (?, ?, ?)
862
- RETURNING *`)
863
- .get(name, description, pinned);
742
+ const tx = this.db.transaction(() => {
743
+ this.requireEpoch(agentId, epoch);
744
+ return this.db
745
+ .prepare(`INSERT INTO rooms (name, description, pinned) VALUES (?, ?, ?)
746
+ RETURNING *`)
747
+ .get(name, description, pinned);
748
+ });
749
+ return tx.immediate();
864
750
  }
865
- setPinned(roomId, pinned) {
751
+ setPinned(roomId, agentId, epoch, pinned) {
866
752
  assertStorable(pinned, "room pinned intro");
867
753
  assertMaxLen(pinned, "room pinned intro", 10_000);
868
- const info = this.db
869
- .prepare("UPDATE rooms SET pinned = ? WHERE id = ?")
870
- .run(pinned, roomId);
871
- // 0 rows = the room vanished under us; report it instead of a false
872
- // success the caller would trust.
873
- if (info.changes === 0) {
874
- throw new Error(`room ${roomId} no longer exists (deleted); rejoin with join_room`);
875
- }
754
+ const tx = this.db.transaction(() => {
755
+ this.requireEpoch(agentId, epoch);
756
+ const info = this.db
757
+ .prepare("UPDATE rooms SET pinned = ? WHERE id = ?")
758
+ .run(pinned, roomId);
759
+ // 0 rows = the room vanished under us; report it instead of a false
760
+ // success the caller would trust.
761
+ if (info.changes === 0) {
762
+ throw new Error(`room ${roomId} no longer exists (deleted); rejoin with join_room`);
763
+ }
764
+ });
765
+ tx.immediate();
876
766
  }
877
767
  getRoom(roomId) {
878
768
  return this.db.prepare("SELECT * FROM rooms WHERE id = ?").get(roomId);
@@ -997,6 +887,17 @@ export class ChatStore {
997
887
  .get(agentId);
998
888
  return c;
999
889
  }
890
+ /** Names of the rooms this persona is present in. Bounded: this feeds an
891
+ * error message, and a persona in hundreds of rooms must not turn one
892
+ * rejection into an unbounded response. */
893
+ presentRoomNames(agentId, limit = 50) {
894
+ return this.db
895
+ .prepare(`SELECT r.name AS name FROM memberships m
896
+ JOIN rooms r ON r.id = m.room_id
897
+ WHERE m.agent_id = ? AND m.left_at IS NULL
898
+ ORDER BY r.id LIMIT ?`)
899
+ .all(agentId, limit).map((r) => r.name);
900
+ }
1000
901
  /** Resolve a room reference that may be a numeric id or a name. */
1001
902
  resolveRoom(ref) {
1002
903
  // Number.isSafeInteger gate: a 16+ digit numeric ref past 2^53 rounds to a
@@ -1015,218 +916,245 @@ export class ChatStore {
1015
916
  }
1016
917
  return this.db.prepare("SELECT * FROM rooms WHERE name = ?").get(ref);
1017
918
  }
1018
- // --- agents / membership ----------------------------------------------
1019
- upsertAgent(id, type, role, description) {
1020
- assertStorable(id, "agent id");
1021
- assertMaxLen(id, "agent id", 200);
1022
- assertStorable(type, "agent type");
1023
- assertMaxLen(type, "agent type", 100);
1024
- assertStorable(role, "agent role");
1025
- assertMaxLen(role, "agent role", 200);
1026
- assertStorable(description, "agent description");
1027
- assertMaxLen(description, "agent description", 2000);
1028
- this.db
1029
- .prepare(`INSERT INTO agents (id, type, role, description)
1030
- VALUES (@id, @type, @role, @description)
1031
- ON CONFLICT(id) DO UPDATE SET
1032
- type = COALESCE(excluded.type, agents.type),
1033
- role = COALESCE(excluded.role, agents.role),
1034
- description = COALESCE(excluded.description, agents.description)`)
1035
- .run({ id, type, role, description });
1036
- }
919
+ // --- personas -----------------------------------------------------------
1037
920
  /**
1038
- * Insert a brand-new agent row, doing nothing if the id is already taken.
1039
- * Returns true only if THIS call created it, so a caller assigning a generated
1040
- * id can claim it atomically: two processes racing on the same candidate id
1041
- * cannot both "win" and collapse onto one shared identity/read-marker.
921
+ * Verify that `epoch` is still the persona's current runtime epoch, throwing
922
+ * PersonaLostError if not.
923
+ *
924
+ * MUST be called from INSIDE the transaction that performs the guarded write.
925
+ * Checking first and writing afterwards is not equivalent: a takeover
926
+ * committing in the gap leaves the check passing and the write landing under
927
+ * an authority the caller no longer holds, which is the entire failure this
928
+ * exists to prevent. Every caller below is inside a tx.immediate().
1042
929
  */
1043
- tryCreateAgent(id, type, role, description) {
1044
- assertStorable(id, "agent id");
1045
- assertMaxLen(id, "agent id", 200);
1046
- assertStorable(type, "agent type");
1047
- assertMaxLen(type, "agent type", 100);
1048
- assertStorable(role, "agent role");
1049
- assertMaxLen(role, "agent role", 200);
1050
- assertStorable(description, "agent description");
1051
- assertMaxLen(description, "agent description", 2000);
1052
- const info = this.db
1053
- .prepare(`INSERT INTO agents (id, type, role, description)
1054
- VALUES (@id, @type, @role, @description)
1055
- ON CONFLICT(id) DO NOTHING`)
1056
- .run({ id, type, role, description });
1057
- return info.changes > 0;
930
+ requireEpoch(agentId, epoch) {
931
+ const row = this.db
932
+ .prepare("SELECT runtime_epoch FROM agents WHERE id = ?")
933
+ .get(agentId);
934
+ 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
+ }
1058
939
  }
1059
940
  /**
1060
- * Recompute memberships.left_at for ONE identity from its session_presence
1061
- * rows: present (left_at NULL) iff any row is LIVE (not left, and refreshed
1062
- * within the GC window); otherwise mark it left. A NO-OP when the identity has
1063
- * NO presence rows at all -- such an identity (a non-session caller: the web
1064
- * viewer, tests, or a pre-redesign build) manages memberships.left_at directly
1065
- * and must not be evicted by a presence recompute.
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.
1066
956
  */
1067
- recomputeMembershipPresence(roomId, agentId) {
1068
- const any = this.db
1069
- .prepare("SELECT 1 FROM session_presence WHERE room_id = ? AND agent_id = ? LIMIT 1")
957
+ requirePresent(roomId, agentId) {
958
+ const row = this.db
959
+ .prepare("SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
1070
960
  .get(roomId, agentId);
1071
- if (!any)
1072
- return;
1073
- const live = this.db
1074
- .prepare(`SELECT 1 FROM session_presence
1075
- WHERE room_id = ? AND agent_id = ? AND left_at IS NULL
1076
- AND updated_at >= datetime('now', ?) LIMIT 1`)
1077
- .get(roomId, agentId, SESSION_GC_AGE);
1078
- if (live) {
1079
- this.db
1080
- .prepare("UPDATE memberships SET left_at = NULL WHERE room_id = ? AND agent_id = ?")
1081
- .run(roomId, agentId);
961
+ if (!row) {
962
+ throw new Error(`you are not a member of room ${roomId}; join_room it first`);
1082
963
  }
1083
- else {
1084
- this.db
1085
- .prepare(`UPDATE memberships SET left_at = datetime('now')
1086
- WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
1087
- .run(roomId, agentId);
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.`);
1088
968
  }
1089
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) {
973
+ const row = this.db
974
+ .prepare("SELECT runtime_epoch FROM agents WHERE id = ?")
975
+ .get(agentId);
976
+ return row ? row.runtime_epoch : null;
977
+ }
978
+ getPersona(id) {
979
+ return this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
980
+ }
1090
981
  /**
1091
- * Reap dead session_presence rows for a room and reconcile presence. Recompute
1092
- * each affected identity FIRST -- while its rows still exist, so an identity
1093
- * whose only rows are expired is marked left rather than mistaken for an
1094
- * unmanaged (no-rows) identity -- THEN delete the left/expired rows.
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).
1095
985
  */
1096
- gcSessionPresence(roomId) {
1097
- // Reap ONLY expired rows (not left-but-fresh ones): a left row must survive
1098
- // its 7-day window because my_mentions reads it to keep muting the room for
1099
- // the session that left -- deleting it on the next unrelated join would
1100
- // silently un-mute. Recompute ignores left rows either way (they are not
1101
- // "live"), so keeping them does not affect identity presence.
1102
- const dead = `updated_at < datetime('now', ?)`;
1103
- const affected = this.db
1104
- .prepare(`SELECT DISTINCT agent_id FROM session_presence WHERE room_id = ? AND (${dead})`)
1105
- .all(roomId, SESSION_GC_AGE);
1106
- for (const { agent_id } of affected) {
1107
- this.recomputeMembershipPresence(roomId, agent_id);
1108
- }
1109
- this.db
1110
- .prepare(`DELETE FROM session_presence WHERE room_id = ? AND (${dead})`)
1111
- .run(roomId, SESSION_GC_AGE);
986
+ tryCreatePersona(p) {
987
+ assertStorable(p.id, "persona id");
988
+ 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);
997
+ assertStorable(p.description, "persona description");
998
+ assertMaxLen(p.description, "persona description", 2000);
999
+ const info = this.db
1000
+ .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)
1003
+ ON CONFLICT(id) DO NOTHING`)
1004
+ .run(p);
1005
+ return info.changes > 0;
1006
+ }
1007
+ /**
1008
+ * Bindingless resume: take over a persona and return the NEW epoch.
1009
+ *
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.
1017
+ *
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.
1024
+ */
1025
+ attachPersona(a) {
1026
+ return this.db
1027
+ .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`);
1031
+ }
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`);
1039
+ }
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));
1047
+ }
1048
+ const next = row.runtime_epoch + 1;
1049
+ 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 } };
1053
+ })
1054
+ .immediate();
1112
1055
  }
1056
+ // --- membership ---------------------------------------------------------
1113
1057
  /**
1114
- * Join (or rejoin) a room: clears any prior leave, refreshes liveness, and
1115
- * registers this session's PRESENCE. presenceId (the process nonce) is set for
1116
- * every MCP session, shared OR private; sessionId (the cursor nonce) only for
1117
- * a private cursor. A null presenceId (non-session caller: web, tests) keeps
1118
- * the old identity-level presence, seeding no presence row.
1058
+ * Join (or rejoin) a room: clears any prior leave and refreshes liveness.
1059
+ *
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.
1062
+ *
1063
+ * `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.
1119
1067
  */
1120
- joinRoom(roomId, agentId, sessionId = null, presenceId = null) {
1121
- // One IMMEDIATE transaction: this was the file's only multi-statement
1122
- // read-then-write path running autocommitted, where a cross-process
1123
- // deleteRoom interleaving between statements surfaced as an opaque
1124
- // NOT NULL/FK constraint error instead of a clean failure.
1068
+ joinRoom(roomId, agentId, epoch, opts = {}) {
1069
+ if (opts.role !== undefined) {
1070
+ assertStorable(opts.role, "role");
1071
+ assertMaxLen(opts.role, "role", 200);
1072
+ assertRole(opts.role);
1073
+ }
1074
+ // One IMMEDIATE transaction: this is a multi-statement read-then-write path,
1075
+ // and a cross-process deleteRoom interleaving between statements otherwise
1076
+ // 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.
1125
1079
  const tx = this.db.transaction(() => {
1080
+ this.requireEpoch(agentId, epoch);
1126
1081
  // Existence check INSIDE the write transaction: the caller resolved the
1127
1082
  // room earlier, and a cross-process delete_room in that window otherwise
1128
1083
  // surfaces as a raw "FOREIGN KEY constraint failed".
1129
1084
  this.requireRoom(roomId);
1130
- this.db
1085
+ const info = this.db
1131
1086
  .prepare("INSERT OR IGNORE INTO memberships (room_id, agent_id) VALUES (?, ?)")
1132
1087
  .run(roomId, agentId);
1088
+ const created = info.changes > 0;
1089
+ if (created && opts.cursorStart === "latest") {
1090
+ // Baseline a NEW membership at the room head so the joiner sees only
1091
+ // what arrives from now on. Read inside this transaction, so a message
1092
+ // committed concurrently is either below the baseline (skipped, as
1093
+ // asked) or above it (delivered) -- never lost between the two.
1094
+ this.db
1095
+ .prepare(`UPDATE memberships
1096
+ SET last_read_seq = COALESCE(
1097
+ (SELECT MAX(seq) FROM messages WHERE room_id = ?), 0)
1098
+ WHERE room_id = ? AND agent_id = ?`)
1099
+ .run(roomId, roomId, agentId);
1100
+ }
1133
1101
  this.db
1134
1102
  .prepare(`UPDATE memberships SET left_at = NULL, last_seen = datetime('now')
1135
1103
  WHERE room_id = ? AND agent_id = ?`)
1136
1104
  .run(roomId, agentId);
1137
- if (presenceId !== null) {
1138
- // Register/refresh this session's presence (present => left_at NULL).
1139
- this.db
1140
- .prepare(`INSERT INTO session_presence (room_id, agent_id, session_id)
1141
- VALUES (?, ?, ?)
1142
- ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1143
- updated_at = datetime('now'), left_at = NULL`)
1144
- .run(roomId, agentId, presenceId);
1145
- // Reap dead presence rows and reconcile memberships.left_at.
1146
- this.gcSessionPresence(roomId);
1147
- }
1148
- if (sessionId !== null) {
1149
- // Private cursor: seed a new one from the identity marker; an existing
1150
- // one keeps its position (refresh only updated_at against the GC, which
1151
- // must never reap the very session this join resumes).
1105
+ if (opts.role !== undefined) {
1152
1106
  this.db
1153
- .prepare(`INSERT INTO session_markers (room_id, agent_id, session_id, last_read_seq)
1154
- VALUES (?, ?, ?, (SELECT last_read_seq FROM memberships WHERE room_id = ? AND agent_id = ?))
1155
- ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1156
- updated_at = datetime('now')`)
1157
- .run(roomId, agentId, sessionId, roomId, agentId);
1158
- this.db
1159
- .prepare("DELETE FROM session_markers WHERE room_id = ? AND updated_at < datetime('now', ?)")
1160
- .run(roomId, SESSION_GC_AGE);
1107
+ .prepare("UPDATE memberships SET role = ? WHERE room_id = ? AND agent_id = ?")
1108
+ .run(opts.role, roomId, agentId);
1161
1109
  }
1110
+ return { created };
1162
1111
  });
1163
- tx.immediate();
1112
+ return tx.immediate();
1164
1113
  }
1165
1114
  /**
1166
- * Drop THIS session's private cursor for a room: called when a session
1167
- * joins a room in SHARED mode, so a leftover private row from an earlier
1168
- * private join cannot keep feeding my_mentions a stale baseline that the
1169
- * session's own catch_up (now shared) no longer uses.
1115
+ * Set or clear this persona's ROOM-LOCAL role. `role` is the new value and
1116
+ * null clears it; there is no "omit to keep" path here, because omission is
1117
+ * handled by not calling this at all. Room-local by construction: the same
1118
+ * persona keeps a different role in every other room.
1170
1119
  */
1171
- clearSessionCursor(roomId, agentId, sessionId) {
1172
- this.db
1173
- .prepare("DELETE FROM session_markers WHERE room_id = ? AND agent_id = ? AND session_id = ?")
1174
- .run(roomId, agentId, sessionId);
1120
+ setRole(roomId, agentId, epoch, role) {
1121
+ assertStorable(role, "role");
1122
+ assertMaxLen(role, "role", 200);
1123
+ assertRole(role);
1124
+ 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.
1129
+ this.requirePresent(roomId, agentId);
1130
+ // Refresh last_seen in the SAME statement. A role change is a deliberate
1131
+ // act in THIS room, so the seat is demonstrably attended here; leaving
1132
+ // last_seen alone would let an explicit set_role in room B refresh only
1133
+ // whatever room the runtime happens to have active.
1134
+ const info = this.db
1135
+ .prepare(`UPDATE memberships SET role = ?, last_seen = datetime('now')
1136
+ WHERE room_id = ? AND agent_id = ?`)
1137
+ .run(role, roomId, agentId);
1138
+ if (info.changes === 0) {
1139
+ throw new Error(`you are not a member of room ${roomId}; join_room it first`);
1140
+ }
1141
+ });
1142
+ tx.immediate();
1143
+ }
1144
+ /** This persona's room-local role, or null. */
1145
+ getRole(roomId, agentId) {
1146
+ const row = this.db
1147
+ .prepare("SELECT role FROM memberships WHERE room_id = ? AND agent_id = ?")
1148
+ .get(roomId, agentId);
1149
+ return row ? row.role : null;
1175
1150
  }
1176
1151
  /**
1177
- * Soft leave: keep the membership row (and read positions) but mark THIS
1178
- * session not present. Private session cursors are deliberately KEPT: a
1179
- * lagging session's true read position lives only in its session_markers row,
1180
- * and dead ones are reaped by the 7-day GC.
1181
- *
1182
- * SESSION-aware via session_presence: mark this session's presence row left,
1183
- * then recompute the identity-level memberships.left_at from the surviving
1184
- * sessions -- present iff any twin (shared OR private) still has a live
1185
- * presence row. So one session leaving never evicts a live twin, for EVERY
1186
- * cursor mode (the earlier session_markers-only twin check saw private
1187
- * sessions only, so shared twins evicted each other and a private leave
1188
- * evicted a shared twin). A caller with no presence row (presenceId null, or
1189
- * a pre-redesign session) falls back to an identity-level leave.
1152
+ * Soft leave: keep the membership row (and its read position and role) but
1153
+ * mark the persona not present. Rejoining resumes exactly where it left off.
1190
1154
  */
1191
- leaveRoom(roomId, agentId, presenceId = null) {
1155
+ leaveRoom(roomId, agentId, epoch) {
1192
1156
  const tx = this.db.transaction(() => {
1193
- // Reconcile stale presence in this room on the way out: broadens the GC
1194
- // beyond join, so a crashed twin's aged row is reaped (and its identity
1195
- // recomputed) whenever anyone leaves, not only on the next join. The
1196
- // active leaver's own row was refreshed on its last join/touch, so it is
1197
- // not expired and is not reaped here.
1198
- this.gcSessionPresence(roomId);
1199
- if (presenceId !== null) {
1200
- const row = this.db
1201
- .prepare("SELECT 1 FROM session_presence WHERE room_id = ? AND agent_id = ? AND session_id = ?")
1202
- .get(roomId, agentId, presenceId);
1203
- if (row) {
1204
- const s = this.db
1205
- .prepare(`UPDATE session_presence SET left_at = datetime('now')
1206
- WHERE room_id = ? AND agent_id = ? AND session_id = ? AND left_at IS NULL`)
1207
- .run(roomId, agentId, presenceId);
1208
- // Reconcile the identity flag from the sessions that remain.
1209
- this.recomputeMembershipPresence(roomId, agentId);
1210
- return s.changes > 0; // true iff this session went present -> left
1211
- }
1212
- // No presence row for THIS session -- e.g. the 7-day GC reaped it while
1213
- // the process stayed alive (idle, no touch). If the identity still has
1214
- // OTHER presence rows (a live twin), reconcile from them rather than
1215
- // blindly evicting via the identity-level leave below (which would defeat
1216
- // the redesign's no-twin-eviction guarantee). Only an identity with NO
1217
- // presence rows at all (web viewer, tests, pre-redesign) takes that path.
1218
- }
1219
- // The same protection is required for a legacy/sessionless leave. During
1220
- // a rolling upgrade it may share an identity with current session-aware
1221
- // twins; an unconditional identity leave must not evict those live rows.
1222
- const anyLive = this.db
1223
- .prepare(`SELECT 1 FROM session_presence
1224
- WHERE room_id = ? AND agent_id = ? AND left_at IS NULL LIMIT 1`)
1225
- .get(roomId, agentId);
1226
- if (anyLive) {
1227
- this.recomputeMembershipPresence(roomId, agentId);
1228
- return false;
1229
- }
1157
+ this.requireEpoch(agentId, epoch);
1230
1158
  const info = this.db
1231
1159
  .prepare(`UPDATE memberships SET left_at = datetime('now'), last_seen = datetime('now')
1232
1160
  WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
@@ -1236,192 +1164,108 @@ export class ChatStore {
1236
1164
  return tx.immediate();
1237
1165
  }
1238
1166
  /**
1239
- * Refresh ALL of a session's cursor rows against the 7-day GC, without
1240
- * touching membership state: used when the session has an identity but no
1241
- * active room (post-leave my_mentions polling must still shield cursors).
1242
- */
1243
- touchSessionMarkers(_agentId, sessionId) {
1244
- // Key on session_id ALONE, not (agent_id, session_id): the session_id is a
1245
- // process-unique nonce, and a single process can switch identity
1246
- // (join_room under a new agent_id). Its earlier identity's private cursor
1247
- // still belongs to THIS live session, so it must keep being refreshed;
1248
- // scoping the refresh to the current agent_id let that older cursor age
1249
- // out and get GC'd, and switching back recreated it at the identity
1250
- // marker, silently skipping everything the private cursor had not read.
1251
- this.db
1252
- .prepare("UPDATE session_markers SET updated_at = datetime('now') WHERE session_id = ?")
1253
- .run(sessionId);
1254
- }
1255
- /**
1256
- * Mark an active agent alive. Also clears the ACTIVE room's left_at (an
1257
- * actively-acting session re-asserts identity presence there). With
1258
- * presenceId, refresh this session's cursor rows (every identity) and the
1259
- * current identity's presence rows in EVERY room against the 7-day GC, and
1260
- * reconcile the active room's presence.
1261
- *
1262
- * One IMMEDIATE transaction: these statements ran as separate autocommits,
1263
- * and another process's GC interleaving between the membership update and
1264
- * the presence upsert could recompute from the half-applied state, leaving
1265
- * a live presence row beside a left membership (an active session hidden
1266
- * from inboxes and pollers) until the next touch.
1167
+ * Mark a persona alive in a room, clearing that room's left_at (an actively
1168
+ * acting runtime re-asserts presence there).
1267
1169
  *
1268
- * The GC runs here too: join/leave/prune alone never reconciled a crashed
1269
- * twin in a stable room, so it could read present:true indefinitely. touch
1270
- * covers exactly the rooms whose agents anyone can list (list_agents reads
1271
- * the caller's ACTIVE room). Reconciliation stays OPPORTUNISTIC overall: a
1272
- * room no live session joins/leaves/prunes/touches keeps stale presence
1273
- * until the next such operation inside it.
1170
+ * EPOCH-FENCED like any other write. A liveness touch looks harmless, but it
1171
+ * is what makes a persona read as present/active to every other agent and to
1172
+ * recipient status; an unfenced touch would let a runtime that has already
1173
+ * 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
1175
+ * correct: the caller treats liveness as best-effort and the next real
1176
+ * operation reports persona_lost.
1274
1177
  */
1275
- touch(roomId, agentId, presenceId = null) {
1178
+ touch(roomId, agentId, epoch) {
1276
1179
  const tx = this.db.transaction(() => {
1180
+ this.requireEpoch(agentId, epoch);
1277
1181
  this.db
1278
- .prepare("UPDATE memberships SET last_seen = datetime('now'), left_at = NULL WHERE room_id = ? AND agent_id = ?")
1182
+ .prepare(`UPDATE memberships SET last_seen = datetime('now'), left_at = NULL
1183
+ WHERE room_id = ? AND agent_id = ?`)
1279
1184
  .run(roomId, agentId);
1280
- if (presenceId !== null) {
1281
- // Re-assert this session's presence in the ACTIVE room (recreating a
1282
- // row the 7-day GC reaped while the process stayed alive but idle), so
1283
- // a NULL memberships.left_at is always backed by a live presence row
1284
- // and a later leave or crash reconciles correctly. The active room is
1285
- // never one the session soft-left (leave clears the session's active
1286
- // room), so this cannot resurrect a left room's presence. Upsert FIRST
1287
- // so the GC below never reaps the toucher itself.
1288
- this.db
1289
- .prepare(`INSERT INTO session_presence (room_id, agent_id, session_id)
1290
- VALUES (?, ?, ?)
1291
- ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1292
- updated_at = datetime('now'), left_at = NULL`)
1293
- .run(roomId, agentId, presenceId);
1294
- this.gcSessionPresence(roomId);
1295
- this.touchSessionAlive(presenceId, agentId);
1296
- }
1297
1185
  });
1298
1186
  tx.immediate();
1299
1187
  }
1300
1188
  /**
1301
- * Shield a live session's cursor AND presence rows from the 7-day GC in EVERY
1302
- * room (keyed by the process nonce, so it also covers rooms the session is not
1303
- * currently active in). CURSOR rows are refreshed nonce-wide, for every
1304
- * identity the session has ever held (see touchSessionMarkers for why).
1305
- * PRESENCE rows are refreshed only for the CURRENT identity: a session that
1306
- * switched identity no longer acts as the old one, so the old identity's
1307
- * presence must age out via the GC and read as left -- a nonce-wide refresh
1308
- * kept it `present` for the life of the process with no way to leave it. The
1309
- * old identity's preserved cursor row means a later rejoin under that id
1310
- * resumes its exact read position. LEFT rows (leave tombstones) are refreshed
1311
- * too -- the GC reaps by updated_at alone, so a live session's my_mentions
1312
- * muting otherwise silently expired at the GC age while the session kept
1313
- * polling; left_at itself is never cleared here, and a dead session's
1314
- * tombstones still age out. Used for the no-active-room case (post-leave
1315
- * my_mentions polling) and by touch().
1316
- */
1317
- touchSessionAlive(sessionId, agentId) {
1318
- this.touchSessionMarkers("", sessionId);
1319
- this.db
1320
- .prepare("UPDATE session_presence SET updated_at = datetime('now') WHERE session_id = ? AND agent_id = ?")
1321
- .run(sessionId, agentId);
1322
- }
1323
- /**
1324
- * Refresh activity for one room captured by a cross-room operation without
1325
- * silently rejoining it. The exact session-presence row must still be live;
1326
- * a left/tombstoned or never-joined session is a no-op even when a twin keeps
1327
- * the identity-level membership present. IMMEDIATE makes the live-row check
1328
- * and refresh atomic with a concurrent leave.
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.
1329
1192
  */
1330
- touchSessionRoom(roomId, agentId, sessionId) {
1193
+ touchJoinedRoom(roomId, agentId, epoch) {
1331
1194
  const tx = this.db.transaction(() => {
1332
- const live = this.db
1333
- .prepare(`SELECT 1 FROM session_presence
1334
- WHERE room_id = ? AND agent_id = ? AND session_id = ?
1335
- AND left_at IS NULL`)
1336
- .get(roomId, agentId, sessionId);
1337
- if (!live)
1338
- return false;
1339
- this.db
1340
- .prepare(`UPDATE session_presence SET updated_at = datetime('now')
1341
- WHERE room_id = ? AND agent_id = ? AND session_id = ?
1342
- AND left_at IS NULL`)
1343
- .run(roomId, agentId, sessionId);
1344
- this.db
1345
- .prepare(`UPDATE memberships SET last_seen = datetime('now'), left_at = NULL
1346
- WHERE room_id = ? AND agent_id = ?`)
1195
+ this.requireEpoch(agentId, epoch);
1196
+ const info = this.db
1197
+ .prepare(`UPDATE memberships SET last_seen = datetime('now')
1198
+ WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
1347
1199
  .run(roomId, agentId);
1348
- return true;
1200
+ return info.changes > 0;
1349
1201
  });
1350
1202
  return tx.immediate();
1351
1203
  }
1352
1204
  /**
1353
- * Open an in-turn wait lease: this (room, agent, session) has a blocking
1354
- * catch_up call pending. TTL covers the wait plus grace, so a hard-killed
1355
- * process cannot leave a permanent "watching" ghost. Expired rows for the
1356
- * room are reaped in passing. IMMEDIATE (read-then-write) and room-checked,
1357
- * so a concurrently deleted room fails with the clean rejoin message
1358
- * instead of a raw FK error.
1205
+ * Open an in-turn wait lease: this persona has a blocking catch_up pending in
1206
+ * this room. TTL covers the wait plus grace, so a hard-killed process cannot
1207
+ * leave a permanent "watching" ghost. Expired rows for the room are reaped in
1208
+ * passing.
1209
+ *
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.
1359
1215
  */
1360
- beginWaitLease(roomId, agentId, sessionId, ttlSeconds) {
1216
+ beginWaitLease(roomId, agentId, epoch, ttlSeconds) {
1361
1217
  const tx = this.db.transaction(() => {
1218
+ this.requireEpoch(agentId, epoch);
1362
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.
1223
+ this.requirePresent(roomId, agentId);
1363
1224
  this.db
1364
1225
  .prepare("DELETE FROM wait_leases WHERE room_id = ? AND expires_at <= datetime('now')")
1365
1226
  .run(roomId);
1366
1227
  this.db
1367
- .prepare(`INSERT INTO wait_leases (room_id, agent_id, session_id, expires_at)
1228
+ .prepare(`INSERT INTO wait_leases (room_id, agent_id, epoch, expires_at)
1368
1229
  VALUES (?, ?, ?, datetime('now', '+' || ? || ' seconds'))
1369
- ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1370
- started_at = datetime('now'), expires_at = excluded.expires_at`)
1371
- .run(roomId, agentId, sessionId, Math.max(1, Math.floor(ttlSeconds)));
1230
+ 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)));
1372
1235
  });
1373
1236
  tx.immediate();
1374
1237
  }
1375
- /** Close an in-turn wait lease (normal return, timeout, or abort alike). */
1376
- endWaitLease(roomId, agentId, sessionId) {
1238
+ /**
1239
+ * Close an in-turn wait lease (normal return, timeout, or abort alike).
1240
+ *
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.
1248
+ */
1249
+ endWaitLease(roomId, agentId, epoch) {
1377
1250
  this.db
1378
- .prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ? AND session_id = ?")
1379
- .run(roomId, agentId, sessionId);
1251
+ .prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ? AND epoch = ?")
1252
+ .run(roomId, agentId, epoch);
1380
1253
  }
1381
1254
  getMembership(roomId, agentId) {
1382
1255
  return this.db
1383
1256
  .prepare("SELECT last_read_seq, left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
1384
1257
  .get(roomId, agentId);
1385
1258
  }
1386
- /**
1387
- * Read the effective cursor: the per-session position when sessionId is set
1388
- * (falling back to the identity marker if the session row does not exist
1389
- * yet), else the identity marker. Read-only, so it is safe inside a deferred
1390
- * (read) transaction.
1391
- */
1392
- getCursor(roomId, agentId, sessionId) {
1393
- const membership = this.getMembership(roomId, agentId);
1394
- if (!membership || sessionId === null)
1395
- return membership;
1396
- const s = this.db
1397
- .prepare("SELECT last_read_seq FROM session_markers WHERE room_id = ? AND agent_id = ? AND session_id = ?")
1398
- .get(roomId, agentId, sessionId);
1399
- return s
1400
- ? { last_read_seq: s.last_read_seq, left_at: membership.left_at }
1401
- : membership;
1259
+ /** Read position for (room, persona). Read-only, so it is safe inside a
1260
+ * deferred (read) transaction. */
1261
+ getCursor(roomId, agentId) {
1262
+ return this.getMembership(roomId, agentId);
1402
1263
  }
1403
- /**
1404
- * Move the cursor to seq. Shared mode sets the identity marker exactly
1405
- * (rewind allowed, preserving mark_read semantics). Session mode upserts the
1406
- * session cursor exactly and raises the identity marker monotonically to the
1407
- * MAX across sessions, keeping it meaningful as "some session of this
1408
- * identity has read this far" for read receipts.
1409
- */
1410
- setCursor(roomId, agentId, sessionId, seq) {
1411
- if (sessionId === null) {
1412
- this.db
1413
- .prepare("UPDATE memberships SET last_read_seq = ? WHERE room_id = ? AND agent_id = ?")
1414
- .run(seq, roomId, agentId);
1415
- return;
1416
- }
1417
- this.db
1418
- .prepare(`INSERT INTO session_markers (room_id, agent_id, session_id, last_read_seq)
1419
- VALUES (?, ?, ?, ?)
1420
- ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1421
- last_read_seq = excluded.last_read_seq, updated_at = datetime('now')`)
1422
- .run(roomId, agentId, sessionId, seq);
1264
+ /** Move the read position to seq exactly. Rewind is allowed, which is what
1265
+ * mark_read's explicit-seq form relies on. */
1266
+ setCursor(roomId, agentId, seq) {
1423
1267
  this.db
1424
- .prepare("UPDATE memberships SET last_read_seq = max(last_read_seq, ?) WHERE room_id = ? AND agent_id = ?")
1268
+ .prepare("UPDATE memberships SET last_read_seq = ? WHERE room_id = ? AND agent_id = ?")
1425
1269
  .run(seq, roomId, agentId);
1426
1270
  }
1427
1271
  /**
@@ -1443,13 +1287,12 @@ export class ChatStore {
1443
1287
  return floor;
1444
1288
  }
1445
1289
  /**
1446
- * Advance shared/private cursors at or beyond a proven safe floor through an
1447
- * accepted self-authored post. The caller derives that floor from the crossing
1448
- * aggregate: it is either the latest peer seq in the room or the posting
1449
- * cursor when no later peer exists. This is a plain indexed marker pass, not a
1450
- * correlated history scan per private session. Sibling marker timestamps are
1451
- * deliberately untouched: active sessions refresh their own liveness, while
1452
- * dead cursors must still GC.
1290
+ * Advance the author's own cursor past a self-authored post, but only from at
1291
+ * or beyond a proven safe floor. The caller derives that floor from the
1292
+ * crossing aggregate: it is either the latest peer seq in the room or the
1293
+ * posting cursor when no later peer exists. Guarding on the floor is what
1294
+ * keeps this from stepping over an unread PEER message that happens to sit
1295
+ * below the new post.
1453
1296
  */
1454
1297
  advanceOwnOnlyCursors(roomId, agentId, throughSeq, safeFloor) {
1455
1298
  this.db
@@ -1457,11 +1300,6 @@ export class ChatStore {
1457
1300
  SET last_read_seq = max(last_read_seq, ?)
1458
1301
  WHERE room_id = ? AND agent_id = ? AND last_read_seq >= ?`)
1459
1302
  .run(throughSeq, roomId, agentId, safeFloor);
1460
- this.db
1461
- .prepare(`UPDATE session_markers
1462
- SET last_read_seq = max(last_read_seq, ?)
1463
- WHERE room_id = ? AND agent_id = ? AND last_read_seq >= ?`)
1464
- .run(throughSeq, roomId, agentId, safeFloor);
1465
1303
  }
1466
1304
  /**
1467
1305
  * Agents in a room, bounded like listRooms: at most `limit` rows (with the
@@ -1469,7 +1307,10 @@ export class ChatStore {
1469
1307
  */
1470
1308
  listAgents(roomId, activeWithinMinutes, filter, limit = 200, after) {
1471
1309
  const PREVIEW = 300;
1472
- const cols = `SELECT a.id, a.type, a.role,
1310
+ // role comes from the MEMBERSHIP, not the persona: it is what this persona
1311
+ // is in THIS room.
1312
+ const cols = `SELECT a.id, a.brand, a.model, a.version, a.is_human,
1313
+ m.role AS role,
1473
1314
  substr(a.description, 1, ${PREVIEW}) AS description,
1474
1315
  CASE WHEN length(a.description) > ${PREVIEW} THEN 1 ELSE 0 END AS description_cut,
1475
1316
  m.joined_at, m.rowid AS _rid,
@@ -1493,7 +1334,13 @@ export class ChatStore {
1493
1334
  // Literal-substring semantics: escape LIKE wildcards so a filter of
1494
1335
  // "50%" matches those three characters, not everything.
1495
1336
  base.like = `%${filter.trim().replace(/[\\%_]/g, "\\$&")}%`;
1496
- cond = ` AND (IFNULL(a.role,'') LIKE @like ESCAPE '\\' OR IFNULL(a.type,'') LIKE @like ESCAPE '\\'
1337
+ // Every field this listing SHOWS is searchable. version was missing, so
1338
+ // "find the runtimes still on v2" returned nothing while the column sat
1339
+ // in the response.
1340
+ cond = ` AND (IFNULL(m.role,'') LIKE @like ESCAPE '\\'
1341
+ OR IFNULL(a.brand,'') LIKE @like ESCAPE '\\'
1342
+ OR IFNULL(a.model,'') LIKE @like ESCAPE '\\'
1343
+ OR IFNULL(a.version,'') LIKE @like ESCAPE '\\'
1497
1344
  OR IFNULL(a.description,'') LIKE @like ESCAPE '\\' OR a.id LIKE @like ESCAPE '\\')`;
1498
1345
  }
1499
1346
  // KEYSET on the MONOTONIC membership rowid (NOT (joined_at, id)): rowid is
@@ -1527,6 +1374,7 @@ export class ChatStore {
1527
1374
  const isWatching = watching === 1;
1528
1375
  return {
1529
1376
  ...rest,
1377
+ is_human: rest.is_human === 1,
1530
1378
  ...(description_cut ? { description_truncated: true } : {}),
1531
1379
  present: left_at === null,
1532
1380
  active: left_at === null &&
@@ -1597,7 +1445,7 @@ export class ChatStore {
1597
1445
  * ANY message from others carries seq above the token, NOTHING is inserted
1598
1446
  * and the reject result carries the crossing messages (bounded previews,
1599
1447
  * per-row directed) so the caller can assess the delta and idempotently
1600
- * retry; a token ahead of this session's effective cursor is invalid (it
1448
+ * retry; a token ahead of this persona's read cursor is invalid (it
1601
1449
  * cannot have come from that cursor's catch_up and would otherwise bypass
1602
1450
  * the guard). The reject baseline is the TOKEN, unlike the accept path's
1603
1451
  * cursor-relative crossed. opts.crossedPreviewChars additionally returns
@@ -1606,7 +1454,7 @@ export class ChatStore {
1606
1454
  * the proven safe peer floor are normalized through the new own row so their
1607
1455
  * recurring probes do not rescan that suffix.
1608
1456
  */
1609
- postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq = null, sessionId = null, opts = {}) {
1457
+ postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq, epoch, opts = {}) {
1610
1458
  // Reject unstorable text BEFORE the transaction: a body with an embedded
1611
1459
  // NUL reads back truncated (SQLite substr/length stop at NUL) and catch_up
1612
1460
  // would advance the marker past the lost tail. mentions are agent ids
@@ -1652,9 +1500,16 @@ export class ChatStore {
1652
1500
  }
1653
1501
  const ifToken = opts.ifLastReadSeq ?? null;
1654
1502
  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
1505
+ // certainly not insert a new one.
1506
+ this.requireEpoch(agentId, epoch);
1655
1507
  // The caller's room reference predates this transaction; a concurrent
1656
1508
  // delete_room otherwise surfaces as a raw FK failure on the INSERT.
1657
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);
1658
1513
  // Lost-response retry: one indexed lookup only when the caller opted in.
1659
1514
  // It precedes CAS/reference validation so a committed first attempt is
1660
1515
  // recoverable even if room state or a referenced parent later changed.
@@ -1705,14 +1560,14 @@ export class ChatStore {
1705
1560
  (!Number.isSafeInteger(ifToken) || ifToken < 0)) {
1706
1561
  throw new Error("if_last_read_seq must be a non-negative safe integer");
1707
1562
  }
1708
- const cursor = this.getCursor(roomId, agentId, sessionId);
1563
+ const cursor = this.getCursor(roomId, agentId);
1709
1564
  const from = cursor?.last_read_seq ?? 0;
1710
1565
  if (ifToken !== null) {
1711
1566
  // A future/wrong-cursor token made the predicate `seq > token` empty
1712
1567
  // and silently disabled the CAS while unread messages still existed.
1713
- // Bind the token to the effective shared/private cursor that could
1714
- // actually have produced it. This is misuse detection, not auth: the
1715
- // optional guard remains a caller-chosen safety primitive.
1568
+ // Bind the token to the read cursor that could actually have produced
1569
+ // it. This is misuse detection, not auth: the optional guard remains a
1570
+ // caller-chosen safety primitive.
1716
1571
  if (ifToken > from) {
1717
1572
  throw new Error(`if_last_read_seq ${ifToken} is ahead of the current read marker ${from}; ` +
1718
1573
  "call catch_up for this room and use its new_last_read_seq");
@@ -1800,8 +1655,8 @@ export class ChatStore {
1800
1655
  // own-only suffix made every 5s poll (and every 500ms blocking probe) walk
1801
1656
  // that suffix forever. If crossing found peers after `from`, its MAX is
1802
1657
  // the room's latest peer row; otherwise `from` itself is known safe. Any
1803
- // cursor at/after that floor can move through this own post. This protects
1804
- // caught-up sibling sessions without scanning history once per sibling.
1658
+ // cursor at/after that floor can move through this own post, without
1659
+ // scanning history.
1805
1660
  const safeCursorFloor = crossing.c > 0 ? crossing.mx : from;
1806
1661
  this.advanceOwnOnlyCursors(roomId, agentId, next, safeCursorFloor);
1807
1662
  // Opt-in crossed previews on an ACCEPTED post, in the same transaction
@@ -1865,8 +1720,6 @@ export class ChatStore {
1865
1720
  return {
1866
1721
  seq: r.seq,
1867
1722
  from: r.agent_id,
1868
- from_type: r.from_type,
1869
- from_role: r.from_role,
1870
1723
  format: r.format,
1871
1724
  ...(r.priority > 0 ? { priority: true } : {}),
1872
1725
  content,
@@ -1918,6 +1771,12 @@ export class ChatStore {
1918
1771
  sz = JSON.stringify(head).length;
1919
1772
  }
1920
1773
  const h = head;
1774
+ // A body cut all the way to empty is an oversized row whatever stage got it
1775
+ // there. Stage 4 sets this explicitly, but stage 2's escaping correction can
1776
+ // reach keep=0 on its own for a control-heavy body, and a caller that reads
1777
+ // `oversized` to decide "fetch this with get_message" must not miss those.
1778
+ if (h.content === "" && codepointLen(r) > 0)
1779
+ h.oversized = true;
1921
1780
  // Stage 3: shed mentions (down to none if needed); size can live
1922
1781
  // entirely in a legal `to` list. Flags set before the deciding measure.
1923
1782
  if (sz > budget && Array.isArray(h.to) && h.to.length > 0) {
@@ -1940,16 +1799,8 @@ export class ChatStore {
1940
1799
  stub.oversized = true;
1941
1800
  sz = JSON.stringify(head).length;
1942
1801
  const half = (s) => safeCut(s, Math.floor(s.length / 2));
1943
- while (sz > budget &&
1944
- ((stub.from_role?.length ?? 0) > 0 ||
1945
- (stub.from_type?.length ?? 0) > 0 ||
1946
- (stub.room_name?.length ?? 0) > 0)) {
1947
- if (stub.from_role)
1948
- stub.from_role = half(stub.from_role);
1949
- if (stub.from_type)
1950
- stub.from_type = half(stub.from_type);
1951
- if (stub.room_name)
1952
- stub.room_name = half(stub.room_name);
1802
+ while (sz > budget && (stub.room_name?.length ?? 0) > 0) {
1803
+ stub.room_name = half(stub.room_name);
1953
1804
  sz = JSON.stringify(head).length;
1954
1805
  }
1955
1806
  // Sender identity shortens last (the seq still identifies the message).
@@ -2183,9 +2034,8 @@ export class ChatStore {
2183
2034
  * messages), one row per (agent, room), oldest pending first (the most
2184
2035
  * starved recipient leads). This is the read a supervisor polls to decide
2185
2036
  * whom to wake; my_mentions is self-scoped and cannot answer it. Markers
2186
- * are identity-level (a lagging private session's cursor is invisible
2187
- * cross-agent, so an agent can be further behind than reported), and
2188
- * idle_seconds is per room membership, matching list_agents. The directed
2037
+ * are per (persona, room), and idle_seconds is per room membership, matching
2038
+ * list_agents. The directed
2189
2039
  * predicate is inlined rather than directedAt(): that helper binds one
2190
2040
  * FIXED id, and here the id varies per membership row. Fetches limit+1 to
2191
2041
  * report truncation without a tail COUNT. Keyset paging makes a bounded
@@ -2360,16 +2210,40 @@ export class ChatStore {
2360
2210
  /**
2361
2211
  * Non-advancing unread probe for the blocking wait: EXACTLY catchUp's
2362
2212
  * predicate (seq > cursor AND agent_id != self, the cursor resolved through
2363
- * the same session selector), minus the fetch and the advance. Any drift
2213
+ * the persona's one marker for the room), minus the fetch and the advance.
2214
+ * Any drift
2364
2215
  * between this predicate and catchUp's makes the wait loop spin (a positive
2365
2216
  * probe whose advancing read returns nothing). Autocommit reads: under WAL
2366
2217
  * they take a shared lock, so a 500ms cadence never contends for the write
2367
2218
  * lock the way catchUp's IMMEDIATE transaction would. Throws when the
2368
2219
  * membership is gone (room deleted mid-wait), same as catchUp.
2220
+ *
2221
+ * EPOCH-FENCED, and this is what bounds how long a fenced-out wait keeps
2222
+ * 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
2224
+ * second 3 of a 25-second wait went right on waiting, holding a lease that
2225
+ * told peers it was watching, and learned nothing until traffic arrived or
2226
+ * the deadline expired. Checking each probe caps that at one interval.
2227
+ *
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
2230
+ * this method already means "the room or membership is gone" -- a wrong and
2231
+ * actively misleading diagnosis. Anchoring on agents keeps the two apart.
2369
2232
  */
2370
- unreadProbe(roomId, agentId, sessionId) {
2371
- const cursor = this.getCursor(roomId, agentId, sessionId);
2372
- if (!cursor)
2233
+ unreadProbe(roomId, agentId, epoch) {
2234
+ 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`)
2240
+ .get({ room: roomId, agent: agentId });
2241
+ 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
+ }
2246
+ if (row.last_read_seq === null)
2373
2247
  throw new Error("not a member of this room");
2374
2248
  // A wait only needs a yes/no wake signal. COUNT(*) rescanned the complete
2375
2249
  // unread tail twice per second per wait (including a large self-authored
@@ -2377,39 +2251,28 @@ export class ChatStore {
2377
2251
  return this.db
2378
2252
  .prepare(`SELECT 1 FROM messages
2379
2253
  WHERE room_id = ? AND seq > ? AND agent_id != ? LIMIT 1`)
2380
- .get(roomId, cursor.last_read_seq, agentId)
2254
+ .get(roomId, row.last_read_seq, agentId)
2381
2255
  ? 1
2382
2256
  : 0;
2383
2257
  }
2384
2258
  /**
2385
2259
  * Bounded per-room unread summary for one agent: every room the agent is
2386
- * present in (rooms this session soft-left are muted) holding unread
2260
+ * present in (rooms this persona soft-left are muted) holding unread
2387
2261
  * messages from others, with total `unread` and `directed` (aimed at the
2388
2262
  * agent) counts, most-directed first. Feeds both my_mentions' by_room arm
2389
- * and catch_up's rooms_with_unread disclosure on an empty read. Session
2390
- * awareness matches my_mentions: with a sessionId, each room baselines off
2391
- * that session's OWN private cursor where one exists (COALESCE to the
2392
- * identity marker). excludeRoomId drops the room just read (catch_up's
2393
- * summary lists OTHER rooms). Fetches limit+1 to report truncation without
2394
- * a tail COUNT. Read-only, so it is safe inside deferred and immediate
2395
- * transactions alike.
2263
+ * and catch_up's rooms_with_unread disclosure on an empty read. Rooms this
2264
+ * persona soft-left are muted by the membership join. excludeRoomId drops the
2265
+ * 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.
2396
2268
  */
2397
- unreadByRoom(agentId, sessionId, limit, excludeRoomId) {
2398
- // '' never collides with a real session id (same convention as myMentions).
2399
- const sessionKey = sessionId ?? "";
2269
+ unreadByRoom(agentId, limit, excludeRoomId) {
2400
2270
  const lim = Math.max(1, Math.floor(limit));
2401
2271
  const excl = excludeRoomId !== undefined ? " AND g.room_id != ?" : "";
2402
2272
  // Placeholders in SQL text order: the directedAt pair (SELECT), the
2403
- // membership join, the session-marker key, the author exclusion, the
2404
- // presence key, the optional room exclusion, then the LIMIT.
2405
- const params = [
2406
- agentId,
2407
- agentId,
2408
- agentId,
2409
- sessionKey,
2410
- agentId,
2411
- sessionKey,
2412
- ];
2273
+ // membership join, the author exclusion, the optional room exclusion, then
2274
+ // the LIMIT.
2275
+ const params = [agentId, agentId, agentId, agentId];
2413
2276
  if (excludeRoomId !== undefined)
2414
2277
  params.push(excludeRoomId);
2415
2278
  params.push(lim + 1);
@@ -2419,14 +2282,9 @@ export class ChatStore {
2419
2282
  FROM messages g
2420
2283
  JOIN memberships mb ON mb.room_id = g.room_id
2421
2284
  AND mb.agent_id = ? AND mb.left_at IS NULL
2422
- LEFT JOIN session_markers sm ON sm.room_id = g.room_id
2423
- AND sm.agent_id = mb.agent_id AND sm.session_id = ?
2424
2285
  JOIN rooms r ON r.id = g.room_id
2425
- WHERE g.seq > COALESCE(sm.last_read_seq, mb.last_read_seq)
2426
- AND g.agent_id != ?
2427
- AND NOT EXISTS (SELECT 1 FROM session_presence sp
2428
- WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
2429
- AND sp.session_id = ? AND sp.left_at IS NOT NULL)${excl}
2286
+ WHERE g.seq > mb.last_read_seq
2287
+ AND g.agent_id != ?${excl}
2430
2288
  GROUP BY g.room_id, r.name
2431
2289
  ORDER BY directed DESC, unread DESC, g.room_id ASC
2432
2290
  LIMIT ?`)
@@ -2442,14 +2300,18 @@ export class ChatStore {
2442
2300
  * and advances over lower-priority rows through a disclosed cutoff. Directed
2443
2301
  * rows always qualify so advancing cannot silently erase my_mentions items.
2444
2302
  *
2445
- * unreadSummary (its sessionId is the RAW process nonce, my_mentions-style,
2446
- * not this room's cursor selector): on an EMPTY read, include a bounded
2303
+ * unreadSummary: on an EMPTY read, include a bounded
2447
2304
  * rooms_with_unread summary of every OTHER room holding unread, computed in
2448
2305
  * the SAME snapshot as the empty determination -- across two separate
2449
2306
  * queries a message arriving in this room could make the read report
2450
2307
  * "empty" while the summary lists this very room.
2451
2308
  */
2452
- catchUp(roomId, agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, sessionId = null, unreadSummary = null) {
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) {
2453
2315
  if (!Number.isSafeInteger(maxBytes) ||
2454
2316
  maxBytes < MIN_CATCH_UP_RESULT_BUDGET ||
2455
2317
  maxBytes > MAX_BULK_RESULT_CHARS) {
@@ -2470,13 +2332,21 @@ export class ChatStore {
2470
2332
  // never returned): a response the client rejects as oversized can no
2471
2333
  // longer strand a peer message behind an advanced marker.
2472
2334
  const tx = this.db.transaction(() => {
2473
- const cursor = this.getCursor(roomId, agentId, sessionId);
2335
+ // ADVANCING read: fenced like a mutation, because it moves the read
2336
+ // marker. A stale runtime whose catch_up committed would consume messages
2337
+ // the current runtime has not seen and can no longer reach.
2338
+ this.requireEpoch(agentId, epoch);
2339
+ const cursor = this.getCursor(roomId, agentId);
2474
2340
  if (!cursor) {
2475
2341
  // Distinguish a real non-member from a room deleted after the caller
2476
2342
  // resolved it. This extra PK lookup runs only on the error path.
2477
2343
  this.requireRoom(roomId);
2478
2344
  throw new Error("not a member of this room");
2479
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);
2480
2350
  const from = cursor.last_read_seq;
2481
2351
  const priorityOnly = unreadSummary?.priorityOnly === true;
2482
2352
  // Captured under the same IMMEDIATE snapshot as the filtered scan. Own
@@ -2537,7 +2407,7 @@ export class ChatStore {
2537
2407
  .get(roomId, from, lastSeq, agentId, agentId, agentId).c;
2538
2408
  }
2539
2409
  if (lastSeq > from) {
2540
- this.setCursor(roomId, agentId, sessionId, lastSeq);
2410
+ this.setCursor(roomId, agentId, lastSeq);
2541
2411
  }
2542
2412
  // Empty read: same-snapshot disclosure of where the traffic actually
2543
2413
  // is. Emitted even when no other room has unread ([]): that positively
@@ -2545,7 +2415,7 @@ export class ChatStore {
2545
2415
  const UNREAD_SUMMARY_MAX = 20;
2546
2416
  let summary = null;
2547
2417
  if (unreadSummary !== null && messages.length === 0) {
2548
- const fetched = this.unreadByRoom(agentId, unreadSummary.sessionId, UNREAD_SUMMARY_MAX, roomId);
2418
+ const fetched = this.unreadByRoom(agentId, UNREAD_SUMMARY_MAX, roomId);
2549
2419
  // The v0.9 summary was appended after catch_up had already spent the
2550
2420
  // entire page budget. Twenty legal, control-heavy room names could
2551
2421
  // inflate a declared 1k response past 25k. Bound the summary within
@@ -2603,11 +2473,9 @@ export class ChatStore {
2603
2473
  * global total order across rooms), each row tagged room_id/room_name.
2604
2474
  *
2605
2475
  * Strictly a PEEK: no read marker moves. An entry clears when its room is
2606
- * actually read (catch_up / mark_read there). Baselines follow the CALLER's
2607
- * cursor: with a sessionId, each room's private session cursor when one
2608
- * exists (falling back to the identity marker), so the inbox never hides a
2609
- * message the same session's catch_up would still deliver; identity-level
2610
- * otherwise. The poller remains identity-level (it cannot know the nonce).
2476
+ * actually read (catch_up / mark_read there). Baselines follow the persona's
2477
+ * one read marker per room, which is the same marker catch_up and the poller
2478
+ * use, so the inbox can never hide a message catch_up would still deliver.
2611
2479
  *
2612
2480
  * Paging: rows come back oldest-first by messages.id (a global total order
2613
2481
  * across rooms); pass next_after_id back as afterId to page past `limit` or
@@ -2622,10 +2490,7 @@ export class ChatStore {
2622
2490
  * cannot bury the entries themselves. Rows and counts read one DEFERRED
2623
2491
  * snapshot.
2624
2492
  */
2625
- myMentions(agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, sessionId = null, afterId = 0) {
2626
- // '' never collides with a real session id, so one query shape serves
2627
- // both shared and private cursors.
2628
- const sessionKey = sessionId ?? "";
2493
+ myMentions(agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, afterId = 0) {
2629
2494
  const tx = this.db.transaction(() => {
2630
2495
  // Fetch ONE more than `limit` so a page cut by the ROW limit (not the
2631
2496
  // byte budget) is detectable. Without the probe, my_mentions was the only
@@ -2636,44 +2501,31 @@ export class ChatStore {
2636
2501
  FROM ${MESSAGE_FROM}
2637
2502
  JOIN memberships mb ON mb.room_id = g.room_id
2638
2503
  AND mb.agent_id = ? AND mb.left_at IS NULL
2639
- LEFT JOIN session_markers sm ON sm.room_id = g.room_id
2640
- AND sm.agent_id = mb.agent_id AND sm.session_id = ?
2641
2504
  JOIN rooms r ON r.id = g.room_id
2642
- WHERE g.seq > COALESCE(sm.last_read_seq, mb.last_read_seq)
2505
+ WHERE g.seq > mb.last_read_seq
2643
2506
  AND g.id > ? AND g.agent_id != ?
2644
2507
  AND ${directedAt("g")}
2645
- AND NOT EXISTS (SELECT 1 FROM session_presence sp
2646
- WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
2647
- AND sp.session_id = ? AND sp.left_at IS NOT NULL)
2648
- ORDER BY g.id ASC LIMIT ?`), [agentId, sessionKey, afterId, agentId, agentId, agentId, sessionKey, limit + 1], maxBytes);
2508
+ ORDER BY g.id ASC LIMIT ?`), [agentId, afterId, agentId, agentId, agentId, limit + 1], maxBytes);
2649
2509
  const hasExtra = fetched.length > limit;
2650
2510
  const rows = hasExtra ? fetched.slice(0, limit) : fetched;
2651
- // total_directed: a SCALAR aggregate over all present, not-this-session-left
2652
- // rooms -- NOT a materialized per-room array reduced in JS (an agent in
2653
- // 100k unread rooms otherwise cloned and sorted the whole set). The
2654
- // NOT EXISTS mutes a room THIS session left even while a twin keeps the
2655
- // identity present. Placeholders: the membership join, the session-marker
2656
- // key, the author exclusion, the directedAt pair, then the presence key.
2511
+ // total_directed: a SCALAR aggregate over every present room -- NOT a
2512
+ // materialized per-room array reduced in JS (a persona in 100k unread
2513
+ // rooms otherwise cloned and sorted the whole set). Placeholders: the
2514
+ // membership join, the author exclusion, then the directedAt pair.
2657
2515
  const { td } = this.db
2658
2516
  .prepare(`SELECT COUNT(*) AS td FROM messages g
2659
2517
  JOIN memberships mb ON mb.room_id = g.room_id
2660
2518
  AND mb.agent_id = ? AND mb.left_at IS NULL
2661
- LEFT JOIN session_markers sm ON sm.room_id = g.room_id
2662
- AND sm.agent_id = mb.agent_id AND sm.session_id = ?
2663
- WHERE g.seq > COALESCE(sm.last_read_seq, mb.last_read_seq)
2519
+ WHERE g.seq > mb.last_read_seq
2664
2520
  AND g.agent_id != ?
2665
- AND ${directedAt("g")}
2666
- AND NOT EXISTS (SELECT 1 FROM session_presence sp
2667
- WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
2668
- AND sp.session_id = ? AND sp.left_at IS NOT NULL)`)
2669
- .get(agentId, sessionKey, agentId, agentId, agentId, sessionKey);
2521
+ AND ${directedAt("g")}`)
2522
+ .get(agentId, agentId, agentId, agentId);
2670
2523
  const total_directed = td;
2671
2524
  // by_room: fetch only the TOP rooms by directed count in SQL (bounded
2672
2525
  // memory), most-directed first, then trim to the byte budget. The query
2673
- // lives in unreadByRoom (shared with catch_up's rooms_with_unread) and
2674
- // carries the same session-aware muting and cursor baselines.
2526
+ // lives in unreadByRoom, shared with catch_up's rooms_with_unread.
2675
2527
  const BY_ROOM_MAX = 4000;
2676
- const byRoomFetch = this.unreadByRoom(agentId, sessionId, BY_ROOM_MAX);
2528
+ const byRoomFetch = this.unreadByRoom(agentId, BY_ROOM_MAX);
2677
2529
  let byRoom = byRoomFetch.rooms;
2678
2530
  // Rooms past BY_ROOM_MAX (the least-directed) were dropped -- flag it.
2679
2531
  const roomLimitHit = byRoomFetch.truncated;
@@ -2775,16 +2627,24 @@ export class ChatStore {
2775
2627
  * clamped to [0, latest]. A lower value re-exposes those messages to catch_up.
2776
2628
  * Returns the previous and new marker plus the room's latest seq.
2777
2629
  */
2778
- markRead(roomId, agentId, seq, sessionId = null) {
2630
+ markRead(roomId, agentId, epoch, seq) {
2779
2631
  const tx = this.db.transaction(() => {
2780
- const cursor = this.getCursor(roomId, agentId, sessionId);
2781
- if (!cursor)
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);
2782
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);
2783
2643
  const { latest } = this.db
2784
2644
  .prepare("SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?")
2785
2645
  .get(roomId);
2786
2646
  const target = seq === undefined ? latest : Math.max(0, Math.min(seq, latest));
2787
- this.setCursor(roomId, agentId, sessionId, target);
2647
+ this.setCursor(roomId, agentId, target);
2788
2648
  return { previous: cursor.last_read_seq, new: target, latest };
2789
2649
  });
2790
2650
  return tx.immediate();
@@ -2836,24 +2696,15 @@ export class ChatStore {
2836
2696
  * Trim a room to its newest `keepLast` messages. Only the oldest are removed,
2837
2697
  * so MAX(seq) is unchanged and future seq numbers stay monotonic.
2838
2698
  */
2839
- pruneMessages(roomId, keepLast, force) {
2699
+ pruneMessages(roomId, agentId, epoch, keepLast, force) {
2840
2700
  // Keep at least the newest message: keepLast=0 would hit OFFSET -1
2841
2701
  // (clamped to 0 by SQLite, silently keeping one row anyway), and deleting
2842
2702
  // ALL rows would reset MAX(seq), breaking the monotonic-seq invariant.
2843
2703
  keepLast = Math.max(1, Math.floor(keepLast));
2844
2704
  const tx = this.db.transaction(() => {
2705
+ this.requireEpoch(agentId, epoch);
2845
2706
  // A deleted room must not report a successful no-op prune.
2846
2707
  this.requireRoom(roomId);
2847
- // Reap EXPIRED private session cursors first (same 7-day window as the
2848
- // GC in joinRoom): that GC only runs on private joins, so a room whose
2849
- // sessions all vanished otherwise kept a dead marker at some ancient
2850
- // seq blocking every future unforced prune.
2851
- this.db
2852
- .prepare("DELETE FROM session_markers WHERE room_id = ? AND updated_at < datetime('now', ?)")
2853
- .run(roomId, SESSION_GC_AGE);
2854
- // Reconcile presence too (reap crashed sessions' aged rows, recompute
2855
- // memberships.left_at) so a prune also refreshes who is present.
2856
- this.gcSessionPresence(roomId);
2857
2708
  const { c: total } = this.db
2858
2709
  .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
2859
2710
  .get(roomId);
@@ -2866,23 +2717,16 @@ export class ChatStore {
2866
2717
  // Refuse to delete a message that ANY member who did NOT author it has
2867
2718
  // not yet read. Members that left are included: soft leave preserves the
2868
2719
  // read position for resume, so their unread is real until they return.
2869
- // Private session cursors count too: the identity marker is the MAX
2870
- // across sessions, so a lagging twin's unread is invisible to it and
2871
- // only the session_markers row knows. (The author has implicitly
2872
- // "seen" its own message, matching catch_up's self-exclusion.) Pass
2873
- // force=true to prune past this.
2720
+ // (The author has implicitly "seen" its own message, matching catch_up's
2721
+ // self-exclusion.) Pass force=true to prune past this.
2874
2722
  const { u } = this.db
2875
2723
  .prepare(`SELECT COUNT(*) AS u FROM messages g
2876
2724
  WHERE g.room_id = ? AND g.seq < ?
2877
- AND (EXISTS (
2725
+ AND EXISTS (
2878
2726
  SELECT 1 FROM memberships mm
2879
2727
  WHERE mm.room_id = g.room_id
2880
2728
  AND mm.last_read_seq < g.seq AND mm.agent_id != g.agent_id
2881
- ) OR EXISTS (
2882
- SELECT 1 FROM session_markers sm
2883
- WHERE sm.room_id = g.room_id
2884
- AND sm.last_read_seq < g.seq AND sm.agent_id != g.agent_id
2885
- ))`)
2729
+ )`)
2886
2730
  .get(roomId, cutoff.seq);
2887
2731
  if (u > 0) {
2888
2732
  // min over markers that actually BLOCK the prune (same predicate
@@ -2890,20 +2734,12 @@ export class ChatStore {
2890
2734
  // harmless laggards, most commonly the doomed messages' own author,
2891
2735
  // whom the refusal itself exempts.
2892
2736
  const { m } = this.db
2893
- .prepare(`SELECT MIN(m) AS m FROM (
2894
- SELECT mm.last_read_seq AS m FROM memberships mm
2895
- WHERE mm.room_id = ? AND EXISTS (
2896
- SELECT 1 FROM messages g WHERE g.room_id = mm.room_id
2897
- AND g.seq < ? AND g.seq > mm.last_read_seq
2898
- AND g.agent_id != mm.agent_id)
2899
- UNION ALL
2900
- SELECT sm.last_read_seq FROM session_markers sm
2901
- WHERE sm.room_id = ? AND EXISTS (
2902
- SELECT 1 FROM messages g WHERE g.room_id = sm.room_id
2903
- AND g.seq < ? AND g.seq > sm.last_read_seq
2904
- AND g.agent_id != sm.agent_id)
2905
- )`)
2906
- .get(roomId, cutoff.seq, roomId, cutoff.seq);
2737
+ .prepare(`SELECT MIN(mm.last_read_seq) AS m FROM memberships mm
2738
+ WHERE mm.room_id = ? AND EXISTS (
2739
+ SELECT 1 FROM messages g WHERE g.room_id = mm.room_id
2740
+ AND g.seq < ? AND g.seq > mm.last_read_seq
2741
+ AND g.agent_id != mm.agent_id)`)
2742
+ .get(roomId, cutoff.seq);
2907
2743
  return {
2908
2744
  deleted: 0,
2909
2745
  kept: total,
@@ -2926,8 +2762,12 @@ export class ChatStore {
2926
2762
  /** Hard-delete a room and all of its messages and memberships. Throws a
2927
2763
  * clean "already deleted" error if another process removed it first,
2928
2764
  * instead of reporting a false success with zero counts. */
2929
- deleteRoom(roomId) {
2765
+ deleteRoom(roomId, agentId, epoch) {
2930
2766
  const tx = this.db.transaction(() => {
2767
+ // Fenced for the same reason as createRoom, and more urgently: this is
2768
+ // the one irreversible operation in the tool surface. No membership is
2769
+ // required (deletion has always been global admin), only a live persona.
2770
+ this.requireEpoch(agentId, epoch);
2931
2771
  this.requireRoom(roomId);
2932
2772
  const { c: messages } = this.db
2933
2773
  .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
@@ -2939,8 +2779,6 @@ export class ChatStore {
2939
2779
  // and so foreign keys to rooms(id) are satisfied.
2940
2780
  this.db.prepare("DELETE FROM messages WHERE room_id = ?").run(roomId);
2941
2781
  this.db.prepare("DELETE FROM memberships WHERE room_id = ?").run(roomId);
2942
- this.db.prepare("DELETE FROM session_markers WHERE room_id = ?").run(roomId);
2943
- this.db.prepare("DELETE FROM session_presence WHERE room_id = ?").run(roomId);
2944
2782
  this.db.prepare("DELETE FROM claims WHERE room_id = ?").run(roomId);
2945
2783
  this.db.prepare("DELETE FROM wait_leases WHERE room_id = ?").run(roomId);
2946
2784
  this.db.prepare("DELETE FROM rooms WHERE id = ?").run(roomId);
@@ -2955,18 +2793,25 @@ export class ChatStore {
2955
2793
  * winner: the read-check and upsert run in one IMMEDIATE transaction, so two
2956
2794
  * simultaneous claimants cannot both be granted (unlike two "I claim X" chat
2957
2795
  * posts, which can cross). Re-claiming your own key renews the TTL; an
2958
- * expired claim is grantable to anyone. Ownership is per agent_id: two
2959
- * sessions sharing an identity share its claims.
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.
2960
2798
  */
2961
- claimResource(roomId, key, agentId, ttlSeconds, note) {
2799
+ claimResource(roomId, key, agentId, epoch, ttlSeconds, note) {
2962
2800
  assertStorable(key, "claim key");
2963
2801
  assertMaxLen(key, "claim key", 500);
2964
2802
  assertStorable(note, "claim note");
2965
2803
  assertMaxLen(note, "claim note", 2000);
2966
2804
  const tx = this.db.transaction(() => {
2805
+ this.requireEpoch(agentId, epoch);
2967
2806
  // Same deleted-room window as postMessage: fail cleanly, not with a
2968
- // raw FK error from the claims INSERT.
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.
2969
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.
2814
+ this.requirePresent(roomId, agentId);
2970
2815
  const row = this.db
2971
2816
  .prepare(`SELECT agent_id, note,
2972
2817
  strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at,
@@ -3004,8 +2849,9 @@ export class ChatStore {
3004
2849
  return tx.immediate();
3005
2850
  }
3006
2851
  /** Release your own claim. Expired claims can be released by anyone. */
3007
- releaseClaim(roomId, key, agentId) {
2852
+ releaseClaim(roomId, key, agentId, epoch) {
3008
2853
  const tx = this.db.transaction(() => {
2854
+ this.requireEpoch(agentId, epoch);
3009
2855
  this.requireRoom(roomId);
3010
2856
  const row = this.db
3011
2857
  .prepare(`SELECT agent_id,