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/poller.js CHANGED
@@ -9,7 +9,7 @@ import { homedir, tmpdir } from "node:os";
9
9
  import { join, resolve } from "node:path";
10
10
  const USAGE = `agent-chat-poller: wait for unread work with one SQLite probe every five seconds.
11
11
  Usage:
12
- poller.js --agent <id> [--room <id|name>] [--session <nonce>]
12
+ poller.js --agent <id> [--room <id|name>] [--epoch <n>]
13
13
  [--owner-pid <pid>]
14
14
  [--mentions-only] [--interval <seconds>] [--timeout <seconds>]
15
15
  [--ok-on-timeout]
@@ -17,7 +17,14 @@ Usage:
17
17
  The interval must be 5..3600 seconds (default 5). The timeout must be
18
18
  1..86400 seconds (default 1200). Exit 0 = work exists; with --ok-on-timeout,
19
19
  exit 0 also reports a quiet deadline as has_updates:false. Without it,
20
- timeout exits 124. Exit 2 = invalid arguments, duplicate watcher, or DB error.
20
+ timeout exits 124. Exit 2 = invalid arguments, duplicate watcher, DB error, or
21
+ a stale binding (the persona was resumed by a newer runtime).
22
+
23
+ --epoch binds this watcher to one runtime tenure of the persona. Every probe
24
+ reads the persona's current epoch; once it moves, this watcher is speaking for
25
+ a runtime that no longer holds the persona, so it exits 2 rather than reporting
26
+ traffic to a seat nobody is sitting in. Omit it only for a diagnostic watch
27
+ that should survive a takeover.
21
28
  `;
22
29
  function argumentError(message) {
23
30
  throw new Error(message);
@@ -34,7 +41,7 @@ function parseInteger(value, flag, min, max) {
34
41
  function parseArgs(argv) {
35
42
  let agent;
36
43
  let room;
37
- let session;
44
+ let epoch;
38
45
  let ownerPid;
39
46
  let db;
40
47
  let mentionsOnly = false;
@@ -60,8 +67,9 @@ function parseArgs(argv) {
60
67
  agent = take();
61
68
  else if (flag === "--room")
62
69
  room = take();
63
- else if (flag === "--session")
64
- session = take();
70
+ else if (flag === "--epoch") {
71
+ epoch = parseInteger(take(), flag, 1, Number.MAX_SAFE_INTEGER);
72
+ }
65
73
  else if (flag === "--owner-pid") {
66
74
  ownerPid = parseInteger(take(), flag, 1, 2_147_483_647);
67
75
  }
@@ -84,7 +92,7 @@ function parseArgs(argv) {
84
92
  timeoutOk = true;
85
93
  }
86
94
  else if (flag === "--since") {
87
- argumentError("--since is intentionally unsupported: frozen baselines can re-fire forever; use --session");
95
+ argumentError("--since is intentionally unsupported: a frozen baseline re-fires forever once crossed; every probe reads the current read marker instead");
88
96
  }
89
97
  else if (flag === "--help" || flag === "-h") {
90
98
  process.stdout.write(USAGE);
@@ -100,12 +108,10 @@ function parseArgs(argv) {
100
108
  argumentError("--agent is too long");
101
109
  if (room && room.length > 500)
102
110
  argumentError("--room is too long");
103
- if (session && session.length > 500)
104
- argumentError("--session is too long");
105
111
  return {
106
112
  agent,
107
113
  room,
108
- session,
114
+ epoch,
109
115
  ownerPid,
110
116
  db,
111
117
  mentionsOnly,
@@ -168,19 +174,23 @@ function acquireLock(path, token) {
168
174
  }
169
175
  const sleep = (milliseconds) => new Promise((done) => setTimeout(done, milliseconds));
170
176
  async function sleepWhileOwnerAlive(milliseconds, ownerPid) {
171
- // Independent/manual pollers need no five-second owner heartbeat; let their
172
- // single timer sleep for the requested interval without needless wakeups.
177
+ // A watcher with no owner has nothing to check for; let its single timer
178
+ // sleep the whole interval instead of waking every five seconds to ask.
173
179
  if (ownerPid === undefined) {
174
180
  await sleep(milliseconds);
175
181
  return true;
176
182
  }
183
+ // An owned watcher subdivides the interval so it notices a dead owner within
184
+ // five seconds rather than at the next probe, which may be an hour away. This
185
+ // is a liveness CHECK of another process, unrelated to the last_seen
186
+ // heartbeat this watcher writes for its own persona.
177
187
  const deadline = Date.now() + milliseconds;
178
188
  for (;;) {
179
189
  const remaining = deadline - Date.now();
180
190
  if (remaining <= 0)
181
191
  return true;
182
192
  await sleep(Math.min(5_000, remaining));
183
- if (ownerPid !== undefined && !processIsAlive(ownerPid))
193
+ if (!processIsAlive(ownerPid))
184
194
  return false;
185
195
  }
186
196
  }
@@ -234,7 +244,15 @@ try {
234
244
  const path = realpathSync(requestedPath);
235
245
  database = new Database(path);
236
246
  database.pragma("busy_timeout = 2000");
237
- database.pragma("query_only = ON");
247
+ // A GENERATED runtime watcher -- one this server's wait_for_messages handed
248
+ // out, identified by carrying both --owner-pid and --epoch -- refreshes its
249
+ // persona's last_seen so an armed seat does not read as offline while the
250
+ // model is between turns. Nothing else the watcher does may write, and a
251
+ // watcher without that pair (a hand-run diagnostic watch) stays strictly
252
+ // read-only.
253
+ const heartbeatEnabled = args.ownerPid !== undefined && args.epoch !== undefined;
254
+ if (!heartbeatEnabled)
255
+ database.pragma("query_only = ON");
238
256
  let resolvedRoom;
239
257
  if (args.room) {
240
258
  const numericRoom = /^\d+$/.test(args.room) && Number.isSafeInteger(Number(args.room));
@@ -248,58 +266,62 @@ try {
248
266
  if (!resolvedRoom)
249
267
  argumentError(`no room "${args.room}"`);
250
268
  }
251
- // A global /tmp/agent-chat-pollers directory makes the first OS user its
252
- // owner (0700) and prevents every other user from starting a watcher. Use a
253
- // per-UID primary directory on POSIX. When this user owns the legacy
254
- // directory, acquire the same scoped lock there first as a zero-runtime-cost
255
- // rolling-upgrade guard: old and new pollers must not bypass each other's
256
- // singleton merely because the lock directory changed.
269
+ // ONE lock directory, per OS user. A shared /tmp/agent-chat-pollers would make
270
+ // the first user to create it the 0700 owner and lock every other user out of
271
+ // running a watcher at all, so POSIX gets a uid suffix. Elsewhere tmpdir() is
272
+ // already per-user.
257
273
  const uid = typeof process.getuid === "function" ? process.getuid() : null;
258
- const legacyLockDir = join(tmpdir(), "agent-chat-pollers");
259
- try {
260
- mkdirSync(legacyLockDir, { recursive: true, mode: 0o700 });
261
- }
262
- catch (error) {
263
- const code = error.code;
264
- if (uid === null || !["EACCES", "EPERM", "EEXIST"].includes(code ?? "")) {
265
- throw error;
266
- }
267
- }
268
- const legacyStat = lstatSync(legacyLockDir);
269
- const lockDirs = [];
270
- if (legacyStat.isDirectory() && (uid === null || legacyStat.uid === uid)) {
271
- lockDirs.push(legacyLockDir);
272
- }
273
- else if (uid === null) {
274
- argumentError(`watcher lock path is not a directory: ${legacyLockDir}`);
274
+ const lockDir = join(tmpdir(), uid === null ? "agent-chat-pollers" : `agent-chat-pollers-${uid}`);
275
+ mkdirSync(lockDir, { recursive: true, mode: 0o700 });
276
+ const lockStat = lstatSync(lockDir);
277
+ if (!lockStat.isDirectory()) {
278
+ argumentError(`watcher lock path is not a directory: ${lockDir}`);
275
279
  }
276
- if (uid !== null) {
277
- const uidLockDir = join(tmpdir(), `agent-chat-pollers-${uid}`);
278
- mkdirSync(uidLockDir, { recursive: true, mode: 0o700 });
279
- const uidStat = lstatSync(uidLockDir);
280
- if (!uidStat.isDirectory() || uidStat.uid !== uid) {
281
- argumentError(`watcher lock directory is owned by another user: ${uidLockDir}`);
282
- }
283
- lockDirs.push(uidLockDir);
280
+ if (uid !== null && lockStat.uid !== uid) {
281
+ argumentError(`watcher lock directory is owned by another user: ${lockDir}`);
284
282
  }
283
+ // The epoch is PART of the singleton key. Two watchers of the same persona
284
+ // and room at different epochs are not duplicates: the older one belongs to a
285
+ // runtime that has already been fenced out and will exit on its next probe.
286
+ // Keying without it made a freshly-resumed runtime unable to arm a watcher
287
+ // until the dying one noticed, leaving the new seat deaf for up to one
288
+ // interval at exactly the moment it most needed to hear.
285
289
  const scopeKey = JSON.stringify([
286
290
  path,
287
291
  args.agent,
288
292
  resolvedRoom?.id ?? null,
289
- args.session ?? null,
293
+ args.epoch ?? null,
290
294
  args.mentionsOnly,
291
295
  ]);
292
296
  const lockName = createHash("sha256").update(scopeKey).digest("hex") + ".lock";
293
- for (const lockDir of lockDirs) {
294
- const lockPath = join(lockDir, lockName);
295
- // Register before acquiring: a signal delivered immediately after the
296
- // synchronous create still lets cleanup find the token-owned file.
297
- lockPaths.push(lockPath);
298
- acquireLock(lockPath, lockToken);
299
- }
300
- const params = {
301
- agent: args.agent,
302
- session: args.session ?? "",
297
+ const lockPath = join(lockDir, lockName);
298
+ // Register before acquiring: a signal delivered immediately after the
299
+ // synchronous create still lets cleanup find the token-owned file.
300
+ lockPaths.push(lockPath);
301
+ acquireLock(lockPath, lockToken);
302
+ const params = { agent: args.agent };
303
+ /**
304
+ * A watcher bound to an epoch stops the moment the persona moves on.
305
+ *
306
+ * The epoch is carried as returned DATA, never as a WHERE predicate. As a
307
+ * predicate it would simply remove the row, and the two probes read a missing
308
+ * row very differently: the scoped one reports "the room was deleted or the
309
+ * membership disappeared", which is a false and actively misleading
310
+ * diagnosis, while the all-rooms one reads it as an ordinary quiet interval
311
+ * and never exits at all. Comparing in JS keeps the true reason attached to
312
+ * the exit.
313
+ */
314
+ const checkEpoch = (current) => {
315
+ if (args.epoch === undefined)
316
+ return;
317
+ if (current === null) {
318
+ argumentError(`persona "${args.agent}" no longer exists; this watcher's binding is dead`);
319
+ }
320
+ if (current !== args.epoch) {
321
+ argumentError(`stale_binding: persona "${args.agent}" is now at runtime epoch ${current}, ` +
322
+ `this watcher was bound at ${args.epoch}. A newer runtime holds the ` +
323
+ `persona; regenerate the poller command from that runtime.`);
324
+ }
303
325
  };
304
326
  const directed = args.mentionsOnly
305
327
  ? ` AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
@@ -309,31 +331,58 @@ try {
309
331
  let probe;
310
332
  if (resolvedRoom) {
311
333
  const membership = database
312
- .prepare("SELECT 1 FROM memberships WHERE room_id = ? AND agent_id = ?")
334
+ .prepare("SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
313
335
  .get(resolvedRoom.id, args.agent);
314
336
  if (!membership) {
315
337
  argumentError(`agent "${args.agent}" has not joined room ${resolvedRoom.id}`);
316
338
  }
339
+ if (membership.left_at !== null) {
340
+ argumentError(`left_room: agent "${args.agent}" has left room ${resolvedRoom.id}; ` +
341
+ `a watcher on a room you are absent from would deliver traffic peers ` +
342
+ `can see you are not receiving. Rejoin with join_room and use the ` +
343
+ `poller command it returns.`);
344
+ }
317
345
  params.room_id = resolvedRoom.id;
346
+ // has_updates, current_epoch and left_at are all COLUMNS of a row this
347
+ // query always returns while the room and membership exist, so "no row"
348
+ // keeps its single unambiguous meaning.
318
349
  const statement = database.prepare(`SELECT r.name AS room_name,
350
+ (SELECT a.runtime_epoch FROM agents a WHERE a.id = @agent)
351
+ AS current_epoch,
352
+ mb.left_at AS left_at,
319
353
  EXISTS (
320
354
  SELECT 1 FROM messages g
321
355
  WHERE g.room_id = r.id
322
- AND g.seq > CASE WHEN @session = '' THEN mb.last_read_seq
323
- ELSE COALESCE(sm.last_read_seq, mb.last_read_seq) END
356
+ AND g.seq > mb.last_read_seq
324
357
  AND g.agent_id != @agent${directed}
325
358
  LIMIT 1
326
359
  ) AS has_updates
327
360
  FROM rooms r
328
361
  JOIN memberships mb ON mb.room_id = r.id AND mb.agent_id = @agent
329
- LEFT JOIN session_markers sm ON sm.room_id = r.id
330
- AND sm.agent_id = mb.agent_id AND sm.session_id = @session
331
362
  WHERE r.id = @room_id`);
332
363
  probe = () => {
333
364
  const row = statement.get(params);
334
365
  if (!row) {
335
366
  argumentError(`room ${resolvedRoom.id} was deleted or the membership disappeared while watching`);
336
367
  }
368
+ checkEpoch(row.current_epoch);
369
+ // Checked on EVERY probe, not just at arm time. A watcher armed while
370
+ // present and left afterwards kept firing invisibly: peers correctly saw
371
+ // the persona absent while its watcher still delivered. left_at rides as
372
+ // returned DATA for the same reason the epoch does -- as a WHERE
373
+ // predicate it would produce "no row", which already means the room or
374
+ // membership is gone, and the two need different diagnostics.
375
+ //
376
+ // This is a DIFFERENT exit from stale_binding. Both say do not re-arm
377
+ // this command, but one means another runtime holds the persona and the
378
+ // other means this persona is not in the room; the next agent should not
379
+ // have to guess which.
380
+ if (row.left_at !== null) {
381
+ argumentError(`left_room: agent "${args.agent}" left room ${resolvedRoom.id} while ` +
382
+ `this watcher was armed; it stops rather than deliver traffic to a ` +
383
+ `room peers can see you are absent from. Rejoin with join_room and ` +
384
+ `use the poller command it returns.`);
385
+ }
337
386
  return row.has_updates
338
387
  ? { room_id: resolvedRoom.id, room_name: row.room_name }
339
388
  : undefined;
@@ -345,33 +394,91 @@ try {
345
394
  .get(args.agent);
346
395
  if (!present)
347
396
  argumentError(`agent "${args.agent}" is not present in any room`);
348
- const statement = database.prepare(`SELECT mb.room_id AS room_id, r.name AS room_name
349
- FROM memberships mb
350
- JOIN rooms r ON r.id = mb.room_id
351
- LEFT JOIN session_markers sm ON sm.room_id = mb.room_id
352
- AND sm.agent_id = mb.agent_id AND sm.session_id = @session
353
- WHERE mb.agent_id = @agent AND mb.left_at IS NULL
354
- AND (@session = '' OR NOT EXISTS (
355
- SELECT 1 FROM session_presence sp
356
- WHERE sp.room_id = mb.room_id AND sp.agent_id = mb.agent_id
357
- AND sp.session_id = @session AND sp.left_at IS NOT NULL
358
- ))
359
- AND EXISTS (
360
- SELECT 1 FROM messages g
361
- WHERE g.room_id = mb.room_id
362
- AND g.seq > CASE WHEN @session = '' THEN mb.last_read_seq
363
- ELSE COALESCE(sm.last_read_seq, mb.last_read_seq) END
364
- AND g.agent_id != @agent${directed}
365
- LIMIT 1
366
- )
367
- LIMIT 1`);
368
- probe = () => statement.get(params);
397
+ // ANCHORED on the persona row, not on memberships. The unread test alone
398
+ // returns ZERO rows on a quiet interval -- the normal case -- so there is no
399
+ // row to hang an epoch column on. Selecting FROM agents makes the result
400
+ // exactly one row whenever the persona exists, carrying the epoch every
401
+ // time and the hit only when there is one. A vanished persona yields no row,
402
+ // which checkEpoch reads as a dead binding rather than as silence.
403
+ const statement = database.prepare(`SELECT a.runtime_epoch AS current_epoch,
404
+ (SELECT mb.room_id FROM memberships mb
405
+ WHERE mb.agent_id = @agent AND mb.left_at IS NULL
406
+ AND EXISTS (
407
+ SELECT 1 FROM messages g
408
+ WHERE g.room_id = mb.room_id
409
+ AND g.seq > mb.last_read_seq
410
+ AND g.agent_id != @agent${directed}
411
+ LIMIT 1
412
+ )
413
+ LIMIT 1) AS room_id
414
+ FROM agents a WHERE a.id = @agent`);
415
+ const roomName = database.prepare("SELECT name FROM rooms WHERE id = ?");
416
+ probe = () => {
417
+ const row = statement.get(params);
418
+ checkEpoch(row ? row.current_epoch : null);
419
+ if (!row || row.room_id === null)
420
+ return undefined;
421
+ // Name lookup only on a HIT, so the quiet path stays one statement.
422
+ const named = roomName.get(row.room_id);
423
+ if (!named)
424
+ return undefined; // deleted between the two reads
425
+ return { room_id: row.room_id, room_name: named.name };
426
+ };
369
427
  }
428
+ /**
429
+ * Liveness heartbeat for a generated runtime watcher.
430
+ *
431
+ * ONE prepared, epoch-fenced UPDATE. The EXISTS clause is the fence: a watcher
432
+ * whose persona has since been resumed elsewhere matches no row, so a stale
433
+ * runtime cannot refresh liveness even in the interval before its next probe
434
+ * notices and exits. It sets last_seen and nothing else -- left_at is never
435
+ * cleared, so this cannot resurrect a seat that left the room. Failure is
436
+ * swallowed: a watcher that cannot write must still report traffic.
437
+ *
438
+ * Cost is one write per two minutes per armed watcher against five-second
439
+ * reads. Its contention impact is UNMEASURED.
440
+ */
441
+ const HEARTBEAT_INTERVAL_MS = 120_000;
442
+ // SCOPE MATTERS. A watcher with --room listens to exactly ONE room, so it may
443
+ // only refresh THAT room. Refreshing every present membership from a scoped
444
+ // watcher tells peers in rooms nobody is watching that this persona is
445
+ // listening there, which is the same false availability the rest of this
446
+ // work exists to remove. An unscoped watcher does watch every present room,
447
+ // so it refreshes them all.
448
+ const heartbeatScope = resolvedRoom ? " AND room_id = @room_id" : "";
449
+ const heartbeatStatement = heartbeatEnabled
450
+ ? database.prepare(`UPDATE memberships SET last_seen = datetime('now')
451
+ WHERE agent_id = @agent AND left_at IS NULL${heartbeatScope}
452
+ AND EXISTS (SELECT 1 FROM agents a
453
+ WHERE a.id = @agent AND a.runtime_epoch = @epoch)`)
454
+ : null;
455
+ let lastHeartbeatMs = 0;
456
+ const heartbeat = () => {
457
+ if (!heartbeatStatement)
458
+ return;
459
+ const now = Date.now();
460
+ if (lastHeartbeatMs !== 0 && now - lastHeartbeatMs < HEARTBEAT_INTERVAL_MS) {
461
+ return;
462
+ }
463
+ // Stamp before running, so a failing write throttles like a succeeding one
464
+ // instead of retrying every probe.
465
+ lastHeartbeatMs = now;
466
+ try {
467
+ heartbeatStatement.run({
468
+ agent: args.agent,
469
+ epoch: args.epoch,
470
+ ...(resolvedRoom ? { room_id: resolvedRoom.id } : {}),
471
+ });
472
+ }
473
+ catch { }
474
+ };
475
+ heartbeat();
370
476
  const deadline = Date.now() + args.timeoutSeconds * 1000;
371
477
  for (;;) {
372
478
  if (args.ownerPid !== undefined && !processIsAlive(args.ownerPid)) {
373
479
  argumentError(`owner MCP process ${args.ownerPid} has ended; regenerate the poller command`);
374
480
  }
481
+ heartbeat();
375
482
  // Probe before deciding that the deadline is quiet. The final sleep lands
376
483
  // on the deadline; checking time first discarded messages that arrived
377
484
  // during that last interval and reported a false timeout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multi-agent-collaboration-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "MCP server: a shared chat room where AI agents coordinate via a SQLite-backed message log",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -12,7 +12,6 @@
12
12
  "type": "module",
13
13
  "bin": {
14
14
  "multi-agent-collaboration-mcp": "dist/index.js",
15
- "agent-chat-mcp": "dist/index.js",
16
15
  "agent-chat-check": "dist/check.js",
17
16
  "agent-chat-poller": "dist/poller.js"
18
17
  },
@@ -30,9 +29,7 @@
30
29
  "web": "node web/server.mjs",
31
30
  "prepare": "node scripts/prepare.mjs",
32
31
  "pretest": "node scripts/prepare.mjs",
33
- "test": "node test/run-suite.mjs",
34
- "pretest:concurrency": "node scripts/prepare.mjs",
35
- "test:concurrency": "node test/concurrent-catchup.mjs"
32
+ "test": "node test/run-suite.mjs"
36
33
  },
37
34
  "engines": {
38
35
  "node": ">=22"
@@ -33,8 +33,10 @@ try {
33
33
 
34
34
  // A timestamp alone makes every no-change rebuild tell already-running MCP
35
35
  // processes they are stale. Hash the executable JS instead: reconnect only
36
- // when the deployed behavior actually differs. Keep the timestamp for human
37
- // diagnostics and as a compatibility fallback for stamps made by old builds.
36
+ // when the deployed behavior actually differs. built_at is kept for human
37
+ // diagnostics and ordering ONLY -- it is never compared to decide staleness,
38
+ // and there is no fallback for a stamp without a hash: every stamp this script
39
+ // writes carries one.
38
40
  const dist = join(root, "dist");
39
41
  const files = [];
40
42
  const walk = (dir) => {
package/web/index.html CHANGED
@@ -1334,29 +1334,12 @@
1334
1334
  // identity and room mapping naming different names; and a ghost
1335
1335
  // sweep's in-flight leave could evict a membership another tab had
1336
1336
  // just re-adopted. Web Locks serialize the whole sequence (awaited
1337
- // server calls included) across same-origin tabs. localhost is a
1338
- // secure context, so navigator.locks exists everywhere this runs; the
1339
- // fallback keeps a degraded single-tab-correct behavior just in case.
1340
- // Tab-local serialization fallback: a promise chain so that even without
1341
- // Web Locks (older engines; localhost is a secure context so this is
1342
- // rare) two membership mutations in the SAME tab cannot interleave their
1343
- // read-modify-write. It does NOT coordinate across tabs -- only Web Locks
1344
- // do that -- but it removes the within-tab race the old
1345
- // Promise.resolve().then(fn) left open.
1346
- let membershipChain = Promise.resolve();
1337
+ // server calls included) across same-origin tabs. localhost is a secure
1338
+ // context, so navigator.locks exists in every engine this viewer runs
1339
+ // in; there is no fallback path, because a same-tab-only fallback would
1340
+ // silently drop the cross-tab guarantee this exists to provide.
1347
1341
  function withMembershipLock(fn) {
1348
- if (navigator.locks && navigator.locks.request) {
1349
- return navigator.locks.request("agent-chat.membership", fn);
1350
- }
1351
- // membershipChain always fulfills (swallowed below), so fn runs once.
1352
- const run = membershipChain.then(() => fn());
1353
- // Keep the chain alive regardless of fn's outcome; swallow here so a
1354
- // rejection does not poison every later mutation.
1355
- membershipChain = run.then(
1356
- () => undefined,
1357
- () => undefined,
1358
- );
1359
- return run;
1342
+ return navigator.locks.request("agent-chat.membership", fn);
1360
1343
  }
1361
1344
 
1362
1345
  // Memberships whose soft-leave failed (e.g. server briefly down during
@@ -1566,9 +1549,14 @@
1566
1549
  }
1567
1550
 
1568
1551
  function label(m) {
1569
- // Humans have no role and type 'human'; their name IS the identity.
1570
- if (m.type === "human") return m.from;
1571
- return m.role || m.type || m.from;
1552
+ // A human's name IS the identity. An LLM persona shows its MODEL, which
1553
+ // the schema requires for every LLM row, so there is nothing to fall
1554
+ // back to: a blank label would mean a row the schema says cannot exist,
1555
+ // and quietly substituting the brand or the id would hide that. Its
1556
+ // ROLE is deliberately not used here, because roles are room-local and
1557
+ // mutable, so labelling an old message with the current role
1558
+ // misreports who wrote it.
1559
+ return m.is_human ? m.from : m.model;
1572
1560
  }
1573
1561
 
1574
1562
  // Append text with http(s) URLs as clickable links. Everything goes in
@@ -1598,15 +1586,10 @@
1598
1586
  const me = identity();
1599
1587
  if (!me || m.from === me) return false;
1600
1588
  if (Array.isArray(m.mentions) && m.mentions.includes(me)) return true;
1601
- // The stored reply author decides (it survives parent pruning and
1602
- // needs no parent row loaded); the in-memory parent lookup remains
1603
- // as a fallback for rows written before the column existed.
1604
- if (m.reply_to_agent) return m.reply_to_agent === me;
1605
- if (m.reply_to_seq) {
1606
- const parent = state.msgs.find((x) => x.seq === m.reply_to_seq);
1607
- if (parent && parent.from === me) return true;
1608
- }
1609
- return false;
1589
+ // The stored reply author decides it outright: it survives parent
1590
+ // pruning and needs no parent row loaded. Both current writers stamp
1591
+ // it, so a reply with no reply_to_agent is simply not directed.
1592
+ return m.reply_to_agent === me;
1610
1593
  }
1611
1594
 
1612
1595
  async function fetchJson(url) {
@@ -1903,11 +1886,8 @@
1903
1886
  // rendered text is not mistaken for the whole message.
1904
1887
  const note = document.createElement("div");
1905
1888
  note.className = "trunc-note";
1906
- // body_length is exact (same UTF-16 unit as body.length) when the
1907
- // row carries a stamped length; rows from old builds fall back to
1908
- // a codepoint count, which can undershoot the shown length for
1909
- // astral-heavy text. Guard against emitting an impossible
1910
- // "showing X of fewer-than-X" notice in that case.
1889
+ // body_length is exact and in the same UTF-16 unit as body.length,
1890
+ // so the comparison below is apples to apples.
1911
1891
  const shown = m.body.length;
1912
1892
  const total = Number(m.body_length);
1913
1893
  note.textContent =