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/index.js CHANGED
@@ -2,13 +2,13 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
- import { randomUUID } from "node:crypto";
5
+ import { randomBytes } from "node:crypto";
6
6
  import { createRequire } from "node:module";
7
7
  import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { readFileSync } from "node:fs";
10
10
  import { z } from "zod";
11
- import { ChatStore, DEFAULT_MAX_BYTES, MAX_CLIENT_MESSAGE_ID_CHARS, MAX_CROSSED_PREVIEW_CHARS, MAX_MESSAGE_BODY_BYTES, MIN_CATCH_UP_RESULT_BUDGET, } from "./db.js";
11
+ import { ChatStore, ModelTupleMismatchError, PersonaLostError, DEFAULT_MAX_BYTES, MAX_AGENT_ID_CHARS, MAX_CLIENT_MESSAGE_ID_CHARS, MAX_CROSSED_PREVIEW_CHARS, MAX_MESSAGE_BODY_BYTES, MIN_CATCH_UP_RESULT_BUDGET, } from "./db.js";
12
12
  import { BoundedLineTransform, MAX_MCP_FRAME_BYTES, } from "./bounded-lines.js";
13
13
  import { stringifyWellFormedJson } from "./unicode.js";
14
14
  // The watcher is a sibling entry. Production executes compiled poller.js;
@@ -40,18 +40,27 @@ function shq(s) {
40
40
  */
41
41
  function pollerCmd(agentId, opts = {}) {
42
42
  let cmd = `${POLLER_COMMAND.map(shq).join(" ")} --agent ${shq(agentId)}`;
43
- // Generated commands belong to this MCP session. If the client reconnects
44
- // and this server exits, its old session-nonce watcher retires within five
45
- // seconds instead of accumulating until a long timeout. Direct CLI commands
46
- // can omit --owner-pid when independent lifetime is intentional.
43
+ // Generated commands belong to this MCP runtime. If the client reconnects
44
+ // and this server exits, the watcher retires within five seconds instead of
45
+ // accumulating until a long timeout. Direct CLI commands can omit
46
+ // --owner-pid when independent lifetime is intentional.
47
+ //
48
+ // --owner-pid together with --epoch is also what marks a watcher as GENERATED
49
+ // rather than hand-run, and only a generated watcher refreshes last_seen.
47
50
  cmd += ` --owner-pid ${shq(String(process.pid))}`;
48
51
  if (opts.room !== undefined)
49
52
  cmd += ` --room ${shq(opts.room)}`;
50
- // Both scoped and all-room watches resolve this session's CURRENT cursor on
51
- // every probe. Never bake a point-in-time --since value into a restartable
52
- // command: once crossed, that stale baseline fires forever.
53
- if (opts.session !== undefined) {
54
- cmd += ` --session ${shq(opts.session)}`;
53
+ // Bind the watcher to the epoch that was current when it was generated. This
54
+ // is a SECOND, independent retirement condition, not a restatement of
55
+ // --owner-pid: the runtime that armed the watcher can stay alive and still
56
+ // lose the persona to a later resume, and from that moment the command speaks
57
+ // for a runtime that no longer holds it. The epoch is what lets the watcher
58
+ // notice and exit instead of reporting traffic to a seat nobody is sitting
59
+ // in. Every probe still resolves the CURRENT read cursor:
60
+ // never bake a point-in-time --since baseline into a restartable command,
61
+ // because once crossed it fires forever.
62
+ if (opts.epoch !== undefined) {
63
+ cmd += ` --epoch ${shq(String(opts.epoch))}`;
55
64
  }
56
65
  // Baked-in loop knobs, so callers stop hand-editing the command string
57
66
  // (the historical -32602 friction: the values LOOK like tool params).
@@ -63,7 +72,7 @@ function pollerCmd(agentId, opts = {}) {
63
72
  }
64
73
  // Generated commands treat an expected quiet deadline as successful
65
74
  // completion. `has_updates` in stdout distinguishes timeout from a hit;
66
- // direct legacy CLI invocations without this flag retain exit 124.
75
+ // direct CLI invocations without this flag retain exit 124.
67
76
  cmd += ` --ok-on-timeout`;
68
77
  if (opts.mentionsOnly)
69
78
  cmd += ` --mentions-only`;
@@ -89,10 +98,10 @@ function configuredWaitCapSeconds() {
89
98
  return value;
90
99
  }
91
100
  const WAIT_CAP_SECONDS = configuredWaitCapSeconds();
92
- // Validate process-level configuration before opening or migrating the shared
101
+ // Validate process-level configuration before opening the shared
93
102
  // database. A typo must fail without mutating production state first.
94
103
  const store = new ChatStore();
95
- const INSTRUCTIONS = `Agent Chat is a local SQLite ledger. Start list_rooms -> join_room -> catch_up. catch_up advances one room; my_mentions peeks across rooms. priority_only is explicitly lossy. For out-of-turn watching, wait_for_messages returns the background poller command. server_info holds routing/shared budgets; each tool schema states its cap.`;
104
+ const INSTRUCTIONS = `Agent Chat is a local SQLite ledger. Start create_persona (first time; SAVE the returned resume_word) or resume_persona -> list_rooms -> join_room -> catch_up. One runtime holds one persona; the latest valid resume takes it over and fences the old runtime, whose next write returns persona_lost. catch_up advances one room; my_mentions peeks across rooms. priority_only is explicitly lossy. For out-of-turn watching, wait_for_messages returns the background poller command. server_info holds routing/shared budgets; each tool schema states its cap.`;
96
105
  // The layered operating manual, served by server_info: stable reference
97
106
  // detail that would otherwise bloat every tools/list. Tool descriptions keep
98
107
  // only their unique semantics and point here.
@@ -106,7 +115,8 @@ ROUTING
106
115
  IN-CALL WAIT
107
116
  - catch_up wait_seconds (0..${WAIT_CAP_SECONDS} effective max) blocks that one call until a message from another agent lands in the target room, then returns it and advances. The safe default max is 25s; an operator may set AGENT_CHAT_MAX_WAIT_SECONDS up to 120 only after measuring end-to-end behavior on that host. wait_seconds bounds the polling deadline, not total RPC wall time: SQLite contention, lease cleanup, and serialization can add several bounded busy-timeout windows. On timeout: timed_out:true, call_again:true, rooms_with_unread. Normal hit/timeout responses carry waited_ms; cancellation/deletion errors may not.
108
117
  - The best-effort watching lease expires wait_seconds+5s after it begins. Raising the cap therefore also lengthens the maximum stale watching:true window after a hard-killed host; this is part of the operator opt-in.
109
- - While your wait is open and its lease write succeeds, peers see watching:true for you (list_agents, post_message recipients): evidence that a blocking call was open, not a delivery guarantee. It drops on normal return/cancellation; TTL bounds a hard-kill ghost. A detached poller never produces it.
118
+ - While your wait is open and its lease write succeeds, peers see watching:true for you (list_agents, post_message recipients): evidence that a blocking call was open, not a delivery guarantee. It drops on normal return/cancellation; TTL bounds a hard-kill ghost. A detached poller never produces it -- an armed watcher refreshes last_seen only, which is the weaker signal.
119
+ - A wait is fenced: every 500ms probe re-reads your epoch, so a takeover ends the wait with terminal persona_lost within one probe rather than at the deadline.
110
120
  - The wait holds your turn open, so it fits "I am waiting for a reply and have nothing else to do". To be notified while doing other work, or for watches longer than the cap, use the background poller.
111
121
 
112
122
  SIZE AND PAGING
@@ -117,43 +127,51 @@ POSTING
117
127
  - crossed counts ALL unread from others past your marker at post time (old backlog included, not only mid-composition arrivals); crossed_directed says how many are aimed at you; crossed_range gives the seq span. If crossed > 0, catch_up before acting on replies. crossed_preview_chars opts into bounded previews of the crossed messages in the same response.
118
128
  - Dispositive posts (verdicts, commissions, dispositions): if_last_read_seq is a conditional post -- rejected (posted:false) if ANYTHING from others landed past your token, with bounded crossed previews returned; call catch_up for the complete delta before retrying. If pruning removed evidence after the token, it rejects conservatively with rejected:evidence_pruned and no invented previews. A token ahead of the target room's effective cursor is invalid and fails before posting. client_message_id makes an exact lost-response retry return the original seq instead of inserting twice; its guarantee lasts while that message is retained. Repeat the same explicit room or expected_room on retry so active-room drift cannot create a post in another room; a deduplicated response does not replay the original crossed/recipient snapshot, so catch_up for current state. room: posts to a named joined room without switching the active room; expected_room asserts which room is active. Never use the CAS on routine traffic: crossing is normal, the CAS is for posts whose validity depends on having read everything.
119
129
  - recipients reports factual room-local state: status, idle_seconds, last_read_seq, marker_behind. A new unread tag normally adds one to marker_behind. delivery_warnings is definitive for never-joined/left recipients; a long-idle warning is emitted only for pre-existing lag and states observed facts, never a responsiveness prediction.
130
+ - status/idle_seconds/last_seen measure LISTENER recency: an MCP call from the bound runtime, or the two-minute heartbeat of a watcher it armed. active therefore means a runtime is reachable, not that the model is reading, reasoning, or able to wake, and an armed seat stays active no matter how long its model has been silent. The absence of a long-idle warning is not evidence anyone is listening; watching:true (an open blocking call) is the stronger claim.
120
131
  - supersedes_seq corrects YOUR OWN earlier message; readers see superseded_by on it. reply_to_seq threads; the log stays flat and globally ordered.
121
132
  - priority:true marks an immutable high-signal checkpoint for priority-only catch-up. Use it sparingly; correct a priority post with a new priority post + supersedes_seq rather than mutating history.
122
133
  - claim/release_claim: atomic single-winner advisory locks with TTL expiry (a crashed holder cannot block forever). Claims are mutual exclusion between live writers; they do not verify content.
123
134
 
124
- MULTIPLE SESSIONS, ONE IDENTITY
125
- - The default shared cursor splits the backlog across concurrent sessions (work-queue style). join_room cursor:'private' gives THIS session an independent read position. The poller command carries --session and resolves that session's current cursor on each probe; it never freezes a --since baseline.
135
+ PERSONAS AND RUNTIMES
136
+ - A PERSONA is the durable identity: its brand/model/version, rooms, read positions, room-local roles, and claims. A RUNTIME is one MCP server process. One runtime holds one persona, and a persona has one runtime at a time.
137
+ - create_persona mints a persona and returns its id and resume_word. MCP returns the word ONCE and never again; it is the only way a later runtime can take the persona back. Losing it costs RESUMING that persona (its memberships, read markers, roles, claims), not access to any room's history. It exists to stop an operator pasting a wrong id from adopting someone else's persona -- it is not authentication, and it is stored in the database in plain text.
138
+ - resume_persona binds an existing persona to this runtime and increments its runtime_epoch. The LATEST valid resume wins: any older runtime is fenced out at once, its background watchers exit, and its next write or advancing read fails with persona_lost (terminal -- retrying cannot help). Re-resuming from the runtime that already holds the persona is a no-op.
139
+ - Every persona-authored write and every marker-advancing read re-verifies the runtime's epoch inside its own transaction, so a fenced-out runtime cannot commit anything.
140
+ - Non-advancing reads (whoami, list_agents, my_mentions, read_history, get_message, get_thread, search_messages, list_claims) still return their normal data and DISCLOSE the loss instead of failing: the response carries persona_lost:true, your_epoch, and current_epoch at top level, the same three fields the persona_lost error uses. current_epoch:null means the persona row is gone. Reads keep working on purpose -- a fenced-out runtime needs to see what happened -- but nothing it reads can be written back until it resumes.
141
+ - The poller command carries --epoch. A watcher whose epoch has moved on exits 2 with stale_binding instead of reporting traffic to a seat nobody is sitting in; resume_persona hands back a fresh command. Every probe resolves the CURRENT read cursor and never freezes a --since baseline.
142
+ - brand/model/version are IMMUTABLE and describe what is actually running. Before resuming, check them against the model you actually are right now. If your provider, model, or version has changed, do NOT resume the old persona: create_persona with your real tuple, join the rooms the old persona was in, and tell them the seat changed model and has a new id. Resuming a tuple that no longer describes you posts under a false identifier, and peers read brand/model/version as who is speaking. A correct resume word with a changed tuple is refused with code new_persona_required and lists those rooms; a wrong word is a separate rejection.
143
+ - Roles are ROOM-LOCAL: set one on join_room or change/clear it with set_role. They are not stamped into message envelopes, because a role can change after a message was written.
126
144
 
127
145
  BACKGROUND POLLER
128
- - Run the command join_room/wait_for_messages return as a BACKGROUND task. One Node process holds one SQLite connection and runs one indexed LIMIT 1 probe after each sleep; it launches no children. Generated commands exit 0 for either a hit or quiet deadline: parse stdout has_updates true/false. Direct CLI calls without --ok-on-timeout retain exit 124 on timeout. Exit 2 is an error or equivalent watcher. Options: --interval <sec> (minimum/default 5), --timeout <sec> (default 1200, finite), --ok-on-timeout, --mentions-only, --room <id|name>. Your own posts never wake it.
146
+ - Run the command join_room/resume_persona/wait_for_messages return as a BACKGROUND task. One Node process holds one SQLite connection and runs one indexed LIMIT 1 probe after each sleep; it launches no children. Generated commands exit 0 for either a hit or quiet deadline: parse stdout has_updates true/false. Direct CLI calls without --ok-on-timeout retain exit 124 on timeout. Exit 2 is an error or equivalent watcher. Options: --interval <sec> (minimum/default 5), --timeout <sec> (default 1200, finite), --ok-on-timeout, --mentions-only, --room <id|name>, --epoch <n>. Your own posts never wake it. Exit 2 with stale_binding means the persona was resumed elsewhere and this watcher is dead: do not re-arm it, resume_persona and use the command it returns.
129
147
  - The poller is an OS-level detector: its exit does NOT by itself schedule your next turn. Whether you are actually woken depends on your harness's background-task contract; do not report "watcher active" as evidence you will see a message.
130
148
 
131
149
  RETENTION
132
150
  - prune_messages deletes old messages (refuses while any non-author member has them unread; force overrides). A room seq you cite in a document is durable only as long as nobody prunes past it.`;
133
- // One stdio server process serves one agent. We remember its identity and
134
- // active room for the session so the agent need not repeat them on every call.
151
+ // One stdio server process is ONE RUNTIME, and a runtime holds at most one
152
+ // persona. The binding lives here, in process memory, and is deliberately not
153
+ // persisted: durability across a restart is the resume word's job, and a second
154
+ // persisted holder token would be a second source of truth about who holds the
155
+ // persona. `epoch` is the value captured when this runtime bound; every write
156
+ // re-verifies it against the stored one inside the write's own transaction.
135
157
  const session = {
136
158
  agentId: null,
159
+ epoch: null,
137
160
  roomId: null,
138
- privateRooms: new Set(),
139
161
  };
140
- /** Key for the per-(room, identity) private-cursor mode set. \u0000 cannot
162
+ /** Stable key for a (room, identity) pair in process-local maps. \u0000 cannot
141
163
  * appear in an agent id (control chars are rejected), so keys never collide. */
142
- function privKey(roomId, agentId) {
164
+ function roomKey(roomId, agentId) {
143
165
  return `${roomId}\u0000${agentId}`;
144
166
  }
145
- // Distinguishes this process's private read cursor (join_room cursor:'private')
146
- // from other sessions running under the same agent_id.
147
- const SESSION_NONCE = randomUUID();
148
- /** The session-cursor key for ACTIVE-room store calls: the nonce when the
149
- * active room was joined with a private cursor UNDER THE CURRENT IDENTITY,
150
- * else null. */
151
- function cursorId() {
152
- return session.roomId !== null &&
153
- session.agentId !== null &&
154
- session.privateRooms.has(privKey(session.roomId, session.agentId))
155
- ? SESSION_NONCE
156
- : null;
167
+ /** The bound persona, or a clean "bind first" error. Every persona-scoped tool
168
+ * goes through this or requireActive(). */
169
+ function requirePersona() {
170
+ if (session.agentId === null || session.epoch === null) {
171
+ throw new Error("no persona bound to this runtime; call create_persona for a new one, " +
172
+ "or resume_persona with an existing id, resume word, and brand/model/version");
173
+ }
174
+ return { agentId: session.agentId, epoch: session.epoch };
157
175
  }
158
176
  // Compact (not pretty-printed) JSON: bulk reads run against a hard client
159
177
  // output cap, and indentation wastes budget that could carry messages.
@@ -166,6 +184,93 @@ function fail(message) {
166
184
  isError: true,
167
185
  };
168
186
  }
187
+ /**
188
+ * Render an error, tagging the one class callers must treat as TERMINAL.
189
+ *
190
+ * persona_lost is not a retryable failure: another runtime holds the persona
191
+ * now, and every future call under this binding fails the same way. The tag
192
+ * exists so a caller can distinguish it from a transient DB error without
193
+ * matching on message text, and `terminal:true` says explicitly not to loop.
194
+ * The binding is cleared here as well: continuing to hold a dead epoch would
195
+ * make every later call re-derive the same answer more slowly.
196
+ */
197
+ function failFrom(e) {
198
+ if (e instanceof PersonaLostError) {
199
+ // Clear ONLY the binding that actually died: same persona AND same epoch.
200
+ // Matching on the id alone let a late error from a superseded epoch wipe a
201
+ // binding this runtime had already re-established. That happens whenever a
202
+ // slow call started before a resume_persona lands after it -- the runtime
203
+ // holds a live epoch, and the stale error tore it down, forcing a
204
+ // pointless second resume.
205
+ if (session.agentId === e.agentId && session.epoch === e.expectedEpoch) {
206
+ session.agentId = null;
207
+ session.epoch = null;
208
+ session.roomId = null;
209
+ }
210
+ return {
211
+ content: [
212
+ {
213
+ type: "text",
214
+ text: JSON.stringify({
215
+ error: e.message,
216
+ code: "persona_lost",
217
+ terminal: true,
218
+ agent_id: e.agentId,
219
+ your_epoch: e.expectedEpoch,
220
+ current_epoch: e.currentEpoch,
221
+ recover: "resume_persona with the id, resume word, and brand/model/version " +
222
+ "to take it back, or create_persona for a new identity",
223
+ }, null, 2),
224
+ },
225
+ ],
226
+ isError: true,
227
+ };
228
+ }
229
+ if (e instanceof ModelTupleMismatchError) {
230
+ // A DISTINCT code from a wrong word, because the remedy is distinct. A
231
+ // wrong word means "find the word"; this means "you are not that
232
+ // participant any more". Rendering both as a generic rejection sent an
233
+ // agent whose model had been upgraded hunting for a credential that was
234
+ // never the problem.
235
+ return {
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text: JSON.stringify({
240
+ error: e.message,
241
+ code: "new_persona_required",
242
+ agent_id: e.agentId,
243
+ persona_model: e.stored,
244
+ your_model: e.offered,
245
+ // The caller has no binding, so it cannot look these up itself.
246
+ // Guidance to notify rooms it cannot name is not guidance.
247
+ persona_rooms: e.rooms,
248
+ recover: "call create_persona with your ACTUAL brand/model/version, " +
249
+ "join the rooms listed in persona_rooms, and post there that " +
250
+ "the seat changed model and carries a new persona id. Do NOT " +
251
+ "resume the old tuple: peers read brand/model/version as who " +
252
+ "is speaking, and it would now be wrong.",
253
+ }, null, 2),
254
+ },
255
+ ],
256
+ isError: true,
257
+ };
258
+ }
259
+ return fail(asMessage(e));
260
+ }
261
+ function lossDisclosure(agentId, epoch) {
262
+ const current = store.currentEpoch(agentId);
263
+ return current === epoch
264
+ ? null
265
+ : { persona_lost: true, your_epoch: epoch, current_epoch: current };
266
+ }
267
+ /** Serialized cost of splicing a disclosure into a response object, charged
268
+ * against a byte budget exactly the way catch_up reserves its routing
269
+ * metadata: merging two non-empty objects replaces `}{` with `,`. Disclosure
270
+ * must not push a bounded read past the budget its caller asked for. */
271
+ function disclosureReserve(d) {
272
+ return d === null ? 0 : JSON.stringify(d).length - 1;
273
+ }
169
274
  /** Cadence of the non-advancing unread probe during a blocking wait. */
170
275
  const WAIT_PROBE_INTERVAL_MS = 500;
171
276
  /** Bound aggregate timer/SQLite pressure when a client accidentally dispatches
@@ -175,6 +280,28 @@ let activeBlockingWaits = 0;
175
280
  /** Wait-lease TTL grace past the deadline, covering the final advancing
176
281
  * read; a hard-killed process's lease self-expires this soon after. */
177
282
  const WAIT_LEASE_GRACE_SECONDS = 5;
283
+ /**
284
+ * How many blocking waits THIS runtime currently has open per (room, persona,
285
+ * epoch).
286
+ *
287
+ * The lease row is keyed (room_id, agent_id) because one persona has one
288
+ * runtime and a takeover must REPLACE the row rather than sit beside it. That
289
+ * keying alone would let two concurrent waits from this same runtime share one
290
+ * row, so whichever finished first would delete it and report the other as not
291
+ * watching. The row means "this persona has at least one wait open here", and
292
+ * only this process knows how many, so the count lives here: the last waiter
293
+ * out closes the lease.
294
+ *
295
+ * The EPOCH is part of this key even though it is not part of the row's key.
296
+ * One process can hold a wait from an old epoch and a wait from a new one at
297
+ * the same time (resume_persona is a tool call, so it can land while an earlier
298
+ * wait sleeps). Sharing a counter across them makes the old wait's exit
299
+ * decrement the new wait's count, and the new wait then closes a lease it does
300
+ * not own -- or, worse, does not close its own. Separate counters keep the two
301
+ * tenures independent; each closes with the epoch it captured, and
302
+ * endWaitLease's epoch guard makes the loser's close a no-op.
303
+ */
304
+ const openWaits = new Map();
178
305
  /** Sleep that wakes EARLY on abort (never rejects; callers re-check
179
306
  * signal.aborted, which is also the correct behavior for an already-aborted
180
307
  * signal). The listener is removed on normal expiry, so a long-lived signal
@@ -201,7 +328,8 @@ function abortableSleep(ms, signal) {
201
328
  });
202
329
  }
203
330
  function requireActive() {
204
- if (session.agentId === null || session.roomId === null) {
331
+ const { agentId, epoch } = requirePersona();
332
+ if (session.roomId === null) {
205
333
  throw new Error("join a room first with join_room");
206
334
  }
207
335
  // The room may have been deleted by another server process; the local session
@@ -212,7 +340,7 @@ function requireActive() {
212
340
  session.roomId = null;
213
341
  throw new Error(`active room ${stale} no longer exists (deleted); rejoin with join_room`);
214
342
  }
215
- return { agentId: session.agentId, roomId: session.roomId };
343
+ return { agentId, epoch, roomId: session.roomId };
216
344
  }
217
345
  /** Resolve an optional explicit joined room without changing the active room.
218
346
  * Explicit operations require an established identity and an existing
@@ -220,70 +348,68 @@ function requireActive() {
220
348
  * is deliberate and may be needed to inspect history or release old claims. */
221
349
  function resolveJoinedRoom(room) {
222
350
  if (room === undefined) {
223
- const { agentId, roomId } = requireActive();
351
+ const { agentId, epoch, roomId } = requireActive();
224
352
  return {
225
353
  agentId,
354
+ epoch,
226
355
  roomId,
227
356
  roomName: store.getRoom(roomId)?.name ?? null,
228
357
  };
229
358
  }
230
- if (session.agentId === null) {
231
- throw new Error("join a room first with join_room to establish your identity");
232
- }
359
+ const { agentId, epoch } = requirePersona();
233
360
  const target = store.resolveRoom(room);
234
361
  if (!target) {
235
362
  throw new Error(`no room "${room}". Use list_rooms to see options.`);
236
363
  }
237
- if (!store.getMembership(target.id, session.agentId)) {
364
+ if (!store.getMembership(target.id, agentId)) {
238
365
  throw new Error(`you have never joined room "${target.name}"; join_room it first`);
239
366
  }
240
367
  return {
241
- agentId: session.agentId,
368
+ agentId,
369
+ epoch,
242
370
  roomId: target.id,
243
371
  roomName: target.name,
244
372
  };
245
373
  }
246
374
  /**
247
- * Mark the active agent (and its private cursor, if any) alive on tool
248
- * invocations. Throttled: every tool call otherwise costs a write transaction
249
- * on the shared file (cross-process lock contention for pure reads like
250
- * list_rooms). 30s granularity is far inside both consumers' tolerances: the
251
- * `active` liveness window is minutes and the session GC is days.
375
+ * Mark the bound persona alive in its active room on tool invocations.
376
+ * Throttled: every tool call otherwise costs a write transaction on the shared
377
+ * file (cross-process lock contention for pure reads like list_rooms). 30s
378
+ * granularity is far inside the `active` liveness window, which is minutes.
379
+ *
380
+ * EPOCH-FENCED through store.touch(), which is what keeps a fenced-out runtime
381
+ * from refreshing liveness: its PersonaLostError lands in the catch below and
382
+ * the write never happened. A stale read therefore leaves last_seen alone.
252
383
  */
253
384
  let lastTouchMs = 0;
254
385
  const TOUCH_INTERVAL_MS = 30_000;
255
386
  function touchSession() {
256
- if (session.agentId === null)
387
+ if (session.agentId === null || session.epoch === null)
388
+ return;
389
+ if (session.roomId === null)
257
390
  return;
258
391
  const now = Date.now();
259
392
  if (now - lastTouchMs < TOUCH_INTERVAL_MS)
260
393
  return;
261
394
  lastTouchMs = now;
262
- // Always pass the nonce (not cursorId()): the ACTIVE room may be shared
263
- // while this session holds private cursors in other rooms, and those rows
264
- // must stay refreshed against the 7-day GC too.
265
395
  try {
266
- if (session.roomId !== null) {
267
- store.touch(session.roomId, session.agentId, SESSION_NONCE);
268
- }
269
- else {
270
- // Identity without an active room (post-leave my_mentions polling):
271
- // still shield this session's cursors AND live presence rows from the GC.
272
- store.touchSessionAlive(SESSION_NONCE, session.agentId);
273
- }
396
+ store.touch(session.roomId, session.agentId, session.epoch);
274
397
  }
275
398
  catch {
276
399
  // Liveness is best-effort: a briefly-locked database must not fail the
277
400
  // tool call this touch piggybacks on (pure reads included). lastTouchMs
278
- // already advanced, so failures back off to the next interval.
401
+ // already advanced, so failures back off to the next interval. A
402
+ // PersonaLostError lands here too and is CORRECTLY swallowed: the write it
403
+ // guards did not happen, which is the point, and the next real operation
404
+ // reports persona_lost to the caller.
279
405
  }
280
406
  }
281
407
  // Cross-room operations capture a target that can differ from mutable active
282
408
  // session state. Throttle each captured (room, identity) independently: using
283
409
  // touchSession() in a named-room wait kept refreshing the active room instead.
284
410
  const capturedTouchMs = new Map();
285
- function touchCapturedRoom(roomId, agentId) {
286
- const key = privKey(roomId, agentId);
411
+ function touchCapturedRoom(roomId, agentId, epoch) {
412
+ const key = roomKey(roomId, agentId);
287
413
  const now = Date.now();
288
414
  if (now - (capturedTouchMs.get(key) ?? 0) < TOUCH_INTERVAL_MS)
289
415
  return;
@@ -302,30 +428,43 @@ function touchCapturedRoom(roomId, agentId) {
302
428
  }
303
429
  capturedTouchMs.set(key, now);
304
430
  try {
305
- // Unlike store.touch(), this requires this exact session's presence row
306
- // to remain live and therefore cannot resurrect an explicitly left room.
307
- store.touchSessionRoom(roomId, agentId, SESSION_NONCE);
431
+ // Unlike store.touch(), this refreshes only a room whose membership is
432
+ // still PRESENT, so it cannot resurrect an explicitly left room.
433
+ store.touchJoinedRoom(roomId, agentId, epoch);
308
434
  }
309
435
  catch {
310
436
  // Best-effort heartbeat, matching touchSession().
311
437
  }
312
438
  }
439
+ const DEV_BUILD = {
440
+ version: "0.0.0-dev",
441
+ commit: "unknown",
442
+ built_at: "",
443
+ artifact_hash: "",
444
+ };
313
445
  const BUILD = (() => {
314
446
  try {
315
447
  return JSON.parse(readFileSync(new URL("./build-info.json", import.meta.url), "utf8"));
316
448
  }
317
449
  catch {
318
- return { version: "0.0.0-dev", commit: "unknown", built_at: "" };
450
+ // Running from an unstamped tree (tsx on src/, or dist copied without the
451
+ // stamp). Not a stale deployment, just an unknown one.
452
+ return DEV_BUILD;
319
453
  }
320
454
  })();
321
455
  /**
322
- * Re-read the on-disk build stamp and report whether a NEWER build has been
456
+ * Re-read the on-disk build stamp and report whether a DIFFERENT build has been
323
457
  * deployed since this process started. If so, this server is stale: the client
324
458
  * should reconnect the MCP to load the new code (a stdio server never
325
- * hot-reloads). Modern stamps compare an executable-artifact hash so rebuilding
326
- * identical code does not emit a false warning; timestamps remain the fallback
327
- * for old stamps. No client UI surfaces serverInfo.version, so this in-band
328
- * flag is the only way an agent learns it is running old code.
459
+ * hot-reloads). No client UI surfaces serverInfo.version, so this in-band flag
460
+ * is the only way an agent learns it is running old code.
461
+ *
462
+ * The comparison is HASH-ONLY. built_at exists for human diagnostics and
463
+ * ordering and is deliberately not consulted: a timestamp comparison called
464
+ * every no-change rebuild a new deployment, and a timestamp FALLBACK for
465
+ * hashless stamps only ever fired for stamps this pipeline no longer writes.
466
+ * If either side has no hash, the honest answer is "not stale", because what is
467
+ * actually known is that the deployment cannot be compared.
329
468
  */
330
469
  function buildStatus() {
331
470
  let latest = null;
@@ -337,14 +476,7 @@ function buildStatus() {
337
476
  }
338
477
  const runningHash = BUILD.artifact_hash ?? "";
339
478
  const latestHash = latest?.artifact_hash ?? "";
340
- const comparableHashes = runningHash.length > 0 && latestHash.length > 0;
341
- const stale = comparableHashes
342
- ? latestHash !== runningHash &&
343
- latest !== null &&
344
- latest.built_at > BUILD.built_at
345
- : latest !== null &&
346
- latest.built_at !== "" &&
347
- latest.built_at > BUILD.built_at;
479
+ const stale = runningHash.length > 0 && latestHash.length > 0 && latestHash !== runningHash;
348
480
  return {
349
481
  stale,
350
482
  latest_commit: latest?.commit ?? null,
@@ -352,6 +484,56 @@ function buildStatus() {
352
484
  latest_artifact_hash: latest?.artifact_hash ?? null,
353
485
  };
354
486
  }
487
+ /**
488
+ * Build identity for a WATCHER HANDOFF.
489
+ *
490
+ * A poller command is unlike every other response this server produces: the
491
+ * caller takes it OUT of the MCP session and runs it as a separate process
492
+ * against the same database, for up to a day. That process is `dist/poller.js`
493
+ * as it exists ON DISK at launch, not the code this server loaded at startup, so
494
+ * the two can be different builds -- and the handoff is the only moment where
495
+ * saying so costs nothing. Every response carrying a poller_cmd states which
496
+ * build minted it and, when a newer one is on disk, what that one is.
497
+ *
498
+ * The command is returned REGARDLESS of staleness, and nothing here fails on it.
499
+ * Withholding the only out-of-turn watching mechanism because a rebuild landed
500
+ * would trade a working watcher for a warning; the caller is told, and decides.
501
+ *
502
+ * Deliberately NOT a poller protocol version: a version number would be a second
503
+ * compatibility surface to maintain, and the artifact hash already answers the
504
+ * only question anyone can act on -- is the disk the same code I am.
505
+ */
506
+ function handoffBuild() {
507
+ const status = buildStatus();
508
+ return {
509
+ server_build: {
510
+ version: BUILD.version,
511
+ commit: BUILD.commit,
512
+ built_at: BUILD.built_at,
513
+ artifact_hash: BUILD.artifact_hash,
514
+ },
515
+ server_stale: status.stale,
516
+ // Only when there IS a readable stamp to compare against. An absent block
517
+ // means "no newer build is known", which is different from "identical".
518
+ ...(status.latest_artifact_hash !== null
519
+ ? {
520
+ latest_build: {
521
+ commit: status.latest_commit,
522
+ built_at: status.latest_built_at,
523
+ artifact_hash: status.latest_artifact_hash,
524
+ },
525
+ }
526
+ : {}),
527
+ ...(status.stale
528
+ ? {
529
+ reconnect_guidance: "a newer build is on disk than this server is running. The command " +
530
+ "above still works -- use it. Reconnect the MCP when convenient so " +
531
+ "the tools and the watcher come from one build (stdio servers do " +
532
+ "not hot-reload).",
533
+ }
534
+ : {}),
535
+ };
536
+ }
355
537
  const server = new McpServer({
356
538
  name: "agent-chat-mcp",
357
539
  version: BUILD.version,
@@ -465,9 +647,12 @@ const LIMITS = {
465
647
  room_name: 200,
466
648
  room_description: 2000,
467
649
  pinned_intro: 10_000,
468
- agent_id: 200,
469
- agent_type: 100,
470
- agent_role: 200,
650
+ agent_id: MAX_AGENT_ID_CHARS,
651
+ persona_brand: 100,
652
+ persona_model: 100,
653
+ persona_version: 100,
654
+ resume_word: 200,
655
+ room_role: 200,
471
656
  agent_description: 2000,
472
657
  claim_key: 500,
473
658
  claim_note: 2000,
@@ -478,7 +663,7 @@ server.registerTool("server_info", {
478
663
  title: "Server info, limits, and operating manual",
479
664
  description: `Version ${BUILD.version}: Report this server's version/build identity, ` +
480
665
  "shared size/response budgets (`limits`), and the full operating " +
481
- "manual (`manual`: routing, paging, poller, multi-session cursors). " +
666
+ "manual (`manual`: routing, paging, poller, personas and runtimes). " +
482
667
  "Call this once when caps or exact semantics matter. `stale:true` = a " +
483
668
  "newer build was deployed since this process started; reconnect the " +
484
669
  "MCP to load it (stdio servers do not hot-reload).",
@@ -502,7 +687,7 @@ server.registerTool("server_info", {
502
687
  });
503
688
  }
504
689
  catch (e) {
505
- return fail(asMessage(e));
690
+ return failFrom(e);
506
691
  }
507
692
  });
508
693
  server.registerTool("what_time_is_it_right_now", {
@@ -524,7 +709,7 @@ server.registerTool("what_time_is_it_right_now", {
524
709
  });
525
710
  }
526
711
  catch (e) {
527
- return fail(asMessage(e));
712
+ return failFrom(e);
528
713
  }
529
714
  });
530
715
  server.registerTool("create_room", {
@@ -554,6 +739,7 @@ server.registerTool("create_room", {
554
739
  }, async ({ name, description, pinned }) => {
555
740
  try {
556
741
  touchSession();
742
+ const { agentId, epoch } = requirePersona();
557
743
  // Room references are resolved id-first (resolveRoom), so an all-digit
558
744
  // name would be shadowed by any room with that numeric id -- and
559
745
  // delete_room resolves the same way, making the ambiguity destructive.
@@ -564,7 +750,7 @@ server.registerTool("create_room", {
564
750
  if (store.getRoomByName(name)) {
565
751
  return fail(`a room named "${name}" already exists`);
566
752
  }
567
- const room = store.createRoom(name, description ?? null, pinned ?? null);
753
+ const room = store.createRoom(name, description ?? null, pinned ?? null, agentId, epoch);
568
754
  return ok({ room_id: room.id, name: room.name });
569
755
  }
570
756
  catch (e) {
@@ -574,7 +760,7 @@ server.registerTool("create_room", {
574
760
  if (/UNIQUE constraint failed: rooms\.name/.test(msg)) {
575
761
  return fail(`a room named "${name}" already exists`);
576
762
  }
577
- return fail(msg);
763
+ return failFrom(e);
578
764
  }
579
765
  });
580
766
  server.registerTool("list_rooms", {
@@ -615,143 +801,336 @@ server.registerTool("list_rooms", {
615
801
  });
616
802
  }
617
803
  catch (e) {
618
- return fail(asMessage(e));
804
+ return failFrom(e);
619
805
  }
620
806
  });
621
- server.registerTool("join_room", {
622
- title: "Join room",
623
- description: "Join a room (id or name) under an identity; sets it active for the " +
624
- "session. Omit agent_id to keep the session's current identity (on the " +
625
- "FIRST join a generated readable id is assigned and returned; reuse it " +
626
- "later to resume the same identity and read position). " +
627
- "type/role/description tell other agents who you are. Read the returned " +
628
- "`pinned` intro. `server_stale:true` = this server runs outdated code; " +
629
- "tell the user to reconnect the MCP. `cursor` (for several sessions " +
630
- "sharing one agent_id): 'shared' (default) = one marker per identity, " +
631
- "concurrent sessions SPLIT the backlog (work-queue style); 'private' = " +
632
- "this session keeps its own cursor (starting from the shared marker) " +
633
- "and sees the full stream independently.",
807
+ server.registerTool("create_persona", {
808
+ title: "Create persona",
809
+ description: "Create a NEW persona and bind it to this runtime. brand/model/version " +
810
+ "are immutable and identify what you are (e.g. 'anthropic'/'claude-opus'/" +
811
+ "'5'); the server derives the canonical id from them. Returns `agent_id` " +
812
+ "and `resume_word`: SAVE BOTH. The resume word is the only way a later " +
813
+ "runtime can take this persona back with its rooms, read positions, " +
814
+ "roles, and claims intact, and MCP returns it ONCE and never again. " +
815
+ "Losing it does not hide any room's history, which any persona can " +
816
+ "still read; what it costs is RESUMING this persona -- its memberships, " +
817
+ "read markers, roles, and claims -- so the only remedy is a new persona " +
818
+ "starting from scratch. If you already have an id and word, call " +
819
+ "resume_persona instead.",
634
820
  inputSchema: z.object({
635
- room: z.string().min(1).max(500).describe("Room id or name to join"),
636
- agent_id: z
821
+ brand: z
637
822
  .string()
638
- .max(200)
639
- .refine((s) => !/[\u0000-\u001f\u007f]/.test(s), {
640
- message: "control characters are not allowed in agent ids",
641
- })
642
- // Reject an empty/whitespace-only id when PROVIDED: the handler trims
643
- // and treats a blank as "omitted" (keep/generate identity), so passing
644
- // " " silently did something other than set that id. Omit the field
645
- // to get that behaviour deliberately; a blank string is an error.
646
- .refine((s) => s.trim().length > 0, {
647
- message: "agent_id cannot be empty or whitespace-only; omit it to keep or auto-assign an identity",
648
- })
649
- .optional()
650
- .describe("Your stable identity/nickname. Omit to keep the session identity " +
651
- "(first join: a readable id is generated and returned)."),
652
- type: z
823
+ .min(1)
824
+ .max(100)
825
+ .describe("Who makes you, e.g. 'anthropic', 'openai'"),
826
+ model: z
653
827
  .string()
828
+ .min(1)
654
829
  .max(100)
655
- .optional()
656
- .describe("Agent type, e.g. 'claude', 'codex', 'gpt'"),
657
- role: z
830
+ .describe("Model family, e.g. 'claude-opus', 'gpt'"),
831
+ version: z
658
832
  .string()
659
- .max(200)
660
- .optional()
661
- .describe("Your role in the room, e.g. 'reviewer', 'planner'"),
833
+ .min(1)
834
+ .max(100)
835
+ .describe("Model version, e.g. '5', '4.5'"),
662
836
  description: z
663
837
  .string()
664
838
  .max(2000)
665
839
  .optional()
666
840
  .describe("Short description of who you are / what you do"),
667
- cursor: z
668
- .enum(["shared", "private"])
841
+ }).strict(),
842
+ }, async ({ brand, model, version, description }) => {
843
+ try {
844
+ if (session.agentId !== null) {
845
+ return fail(`this runtime already holds persona "${session.agentId}"; one runtime ` +
846
+ "holds one persona. Reconnect the MCP to start a fresh runtime.");
847
+ }
848
+ const { id, resumeWord } = createPersona(brand, model, version, description ?? null);
849
+ // A freshly created persona is at epoch 1 by construction (the column
850
+ // default), and this runtime created it, so it is the holder.
851
+ session.agentId = id;
852
+ session.epoch = 1;
853
+ return ok({
854
+ agent_id: id,
855
+ resume_word: resumeWord,
856
+ brand,
857
+ model,
858
+ version,
859
+ // persona_description, never a bare `description`: every persona
860
+ // response uses one name for this string so it can never be read as
861
+ // the room's.
862
+ persona_description: description ?? null,
863
+ runtime_epoch: 1,
864
+ save_this: "Store agent_id and resume_word now. resume_word is shown only here " +
865
+ "and cannot be recovered or reset; without it this persona's history " +
866
+ "is unreachable from any future runtime.",
867
+ next: "join_room to enter a room.",
868
+ server_stale: buildStatus().stale,
869
+ });
870
+ }
871
+ catch (e) {
872
+ return failFrom(e);
873
+ }
874
+ });
875
+ server.registerTool("resume_persona", {
876
+ title: "Resume persona",
877
+ description: "Bind an EXISTING persona to this runtime, recovering its rooms, read " +
878
+ "positions, room-local roles, and claims. Requires the agent_id, its " +
879
+ "resume_word, and brand/model/version matching what it was created with. " +
880
+ "The latest valid resume WINS: any older runtime still holding this " +
881
+ "persona is fenced out immediately and its next write fails with " +
882
+ "persona_lost. Re-calling this from the runtime that already holds the " +
883
+ "persona is a no-op and does not fence anything.",
884
+ inputSchema: z.object({
885
+ agent_id: z
886
+ .string()
887
+ .min(1)
888
+ .max(200)
889
+ .describe("The persona id returned by create_persona"),
890
+ resume_word: z
891
+ .string()
892
+ .min(1)
893
+ .max(200)
894
+ .describe("The resume word returned by create_persona"),
895
+ brand: z.string().min(1).max(100).describe("Must match the persona's brand"),
896
+ model: z.string().min(1).max(100).describe("Must match the persona's model"),
897
+ version: z
898
+ .string()
899
+ .min(1)
900
+ .max(100)
901
+ .describe("Must match the persona's version"),
902
+ }).strict(),
903
+ }, async ({ agent_id, resume_word, brand, model, version }) => {
904
+ try {
905
+ // A runtime may not silently switch personas: binding a second one would
906
+ // leave the first still bound in every caller's mental model while this
907
+ // process quietly acted as someone else.
908
+ if (session.agentId !== null && session.agentId !== agent_id) {
909
+ return fail(`this runtime already holds persona "${session.agentId}"; it cannot ` +
910
+ `switch to "${agent_id}". Reconnect the MCP to start a fresh runtime.`);
911
+ }
912
+ // IDEMPOTENT re-attach: this runtime already holds the persona AND its
913
+ // epoch is still current, so there is nothing to take over. Skipping the
914
+ // increment matters -- incrementing here would invalidate this runtime's
915
+ // OWN outstanding pollers and open waits for no reason.
916
+ //
917
+ // ONE row read decides both questions. Asking currentEpoch() and then
918
+ // getPersona() in separate autocommits leaves a window where a takeover
919
+ // lands between them, and the "still current" branch would then answer
920
+ // with this runtime's dead epoch while another runtime already holds the
921
+ // persona -- a false all-clear at the one moment the caller needed the
922
+ // truth. Reading epoch and credentials from the same snapshot removes the
923
+ // window: if the epoch on THIS row is ours, these credentials are the ones
924
+ // that were current when the epoch was.
925
+ const held = session.agentId === agent_id && session.epoch !== null
926
+ ? store.getPersona(agent_id)
927
+ : undefined;
928
+ if (held && held.runtime_epoch === session.epoch) {
929
+ // Validate the credentials even though nothing is being taken over. The
930
+ // obvious use of this call is a holder CHECKING that the word it wrote
931
+ // down still works, and returning success without looking would confirm
932
+ // a mistranscribed word -- discovered only after a crash, when it is the
933
+ // one thing that cannot be recovered. Validation here never increments,
934
+ // so a holder can re-verify as often as it likes without fencing itself.
935
+ // Same split as attachPersona: the word is the credential, the tuple is
936
+ // identity. A holder re-verifying with a changed model must get the
937
+ // new_persona_required answer here too, not a credential rejection --
938
+ // this branch is the one a long-running runtime hits most.
939
+ if (held.resume_word !== resume_word) {
940
+ return fail(`resume rejected for persona "${agent_id}": the resume word does ` +
941
+ `not match the one it was created with. This runtime still holds ` +
942
+ `the persona -- nothing was fenced -- but this word would NOT ` +
943
+ `recover it later.`);
944
+ }
945
+ if (held.brand !== brand ||
946
+ held.model !== model ||
947
+ held.version !== version) {
948
+ return failFrom(new ModelTupleMismatchError(agent_id, {
949
+ brand: held.brand,
950
+ model: held.model,
951
+ version: held.version,
952
+ }, { brand, model, version }, store.presentRoomNames(agent_id)));
953
+ }
954
+ return ok({
955
+ agent_id,
956
+ runtime_epoch: session.epoch,
957
+ brand: held.brand,
958
+ model: held.model,
959
+ version: held.version,
960
+ persona_description: held.description,
961
+ reattached: true,
962
+ note: "already bound to this runtime; nothing was fenced. The resume " +
963
+ "word and metadata were validated: they will recover this persona.",
964
+ poller_cmd: pollerCmd(agent_id, { epoch: session.epoch }),
965
+ ...handoffBuild(),
966
+ });
967
+ }
968
+ const { epoch, persona } = store.attachPersona({
969
+ id: agent_id,
970
+ resumeWord: resume_word,
971
+ brand,
972
+ model,
973
+ version,
974
+ });
975
+ const previous = session.agentId === agent_id ? session.epoch : null;
976
+ session.agentId = agent_id;
977
+ session.epoch = epoch;
978
+ // The active room does NOT survive a takeover: room membership is durable
979
+ // but "which room am I looking at" is runtime-local state this runtime
980
+ // never had. Rejoin explicitly.
981
+ session.roomId = null;
982
+ return ok({
983
+ agent_id,
984
+ runtime_epoch: epoch,
985
+ brand: persona.brand,
986
+ model: persona.model,
987
+ version: persona.version,
988
+ persona_description: persona.description,
989
+ reattached: false,
990
+ ...(previous !== null ? { previous_epoch: previous } : {}),
991
+ fenced: "any runtime holding this persona at an earlier epoch is now fenced " +
992
+ "out; its pollers exit and its next write fails with persona_lost",
993
+ // Old poller commands carry the OLD epoch and will exit as stale. Hand
994
+ // back a live one in the same response so the caller is never left
995
+ // silently deaf holding a command that can no longer fire.
996
+ poller_cmd: pollerCmd(agent_id, { epoch }),
997
+ next: "join_room to enter a room; memberships and read positions are intact.",
998
+ ...handoffBuild(),
999
+ });
1000
+ }
1001
+ catch (e) {
1002
+ return failFrom(e);
1003
+ }
1004
+ });
1005
+ server.registerTool("join_room", {
1006
+ title: "Join room",
1007
+ description: "Join a room (id or name) and make it active for this runtime. Requires " +
1008
+ "a bound persona (create_persona or resume_persona first). Returns your " +
1009
+ "persona (brand/model/version, persona_description) alongside the room " +
1010
+ "(room_name, room_description, pinned) and your room-local `role`. " +
1011
+ "Rejoining RESUMES your read position; `cursor_start` only applies the " +
1012
+ "first time you join a given room. `role` is ROOM-LOCAL: it describes " +
1013
+ "what you are in this room and does not follow you to others (change or " +
1014
+ "clear it later with set_role). Read the returned `pinned` intro. " +
1015
+ "`server_stale:true` = this server runs outdated code; tell the user to " +
1016
+ "reconnect the MCP.",
1017
+ inputSchema: z.object({
1018
+ room: z.string().min(1).max(500).describe("Room id or name to join"),
1019
+ role: z
1020
+ .string()
1021
+ .min(1)
1022
+ .max(200)
1023
+ .optional()
1024
+ .describe("Your role IN THIS ROOM, e.g. 'reviewer', 'planner'. Omitted: leave " +
1025
+ "any existing role unchanged. Use set_role to change or clear it. " +
1026
+ "Must be non-blank; null (via set_role) is the only way to say " +
1027
+ "'no role'."),
1028
+ cursor_start: z
1029
+ .enum(["beginning", "latest"])
669
1030
  .optional()
670
- .describe("'shared': one read marker per identity, concurrent sessions split " +
671
- "the backlog. 'private': this session keeps its own read " +
672
- "position. Omitted: keeps this session's current mode for the " +
673
- "room (shared on first join); only an explicit 'shared' discards " +
674
- "an existing private cursor."),
1031
+ .describe("Where to start reading when this call CREATES the membership: " +
1032
+ "'beginning' (default) delivers the room's whole history, 'latest' " +
1033
+ "starts from now and skips the backlog. Ignored on a rejoin, which " +
1034
+ "always resumes your saved position."),
675
1035
  }).strict(),
676
- }, async ({ room, agent_id, type, role, description, cursor }) => {
1036
+ }, async ({ room, role, cursor_start }) => {
677
1037
  try {
678
1038
  touchSession();
1039
+ const { agentId, epoch } = requirePersona();
679
1040
  const target = store.resolveRoom(room);
680
1041
  if (!target) {
681
1042
  return fail(`no room "${room}". Use list_rooms to see options or create_room to make one.`);
682
1043
  }
683
- let id;
684
- if (agent_id && agent_id.trim().length > 0) {
685
- id = agent_id.trim();
686
- store.upsertAgent(id, type ?? null, role ?? null, description ?? null);
687
- }
688
- else if (session.agentId !== null) {
689
- // STICKY identity: a session that already established who it is
690
- // keeps that identity on later joins. Generating a fresh id here
691
- // forked the session into a second identity whose twin kept its own
692
- // markers and memberships -- silent state the caller never asked for.
693
- id = session.agentId;
694
- store.upsertAgent(id, type ?? null, role ?? null, description ?? null);
695
- }
696
- else {
697
- // Generated ids are claimed atomically inside assignReadableId, so no
698
- // separate upsert here (it would risk clobbering a racing assigner).
699
- id = assignReadableId(type ?? null, role ?? null, description ?? null);
700
- }
701
- // Session state mutates only AFTER the join succeeds: flipping cursor
702
- // mode first would leave a failed join having silently changed the mode
703
- // for the still-active previous room.
704
- // Cursor mode is STICKY per (room, identity): an omitted `cursor` keeps
705
- // this session's existing mode FOR THIS IDENTITY. Treating omission as
706
- // an explicit 'shared' used to DELETE the session's private cursor on
707
- // any rejoin, and a room-only key let ANOTHER identity's shared join
708
- // clear this identity's mode with the same silent-loss outcome. Only an
709
- // explicit 'shared' downgrades, and only for the identity that joins.
710
- const key = privKey(target.id, id);
711
- const priv = cursor === "private" ||
712
- (cursor === undefined && session.privateRooms.has(key));
713
- store.joinRoom(target.id, id, priv ? SESSION_NONCE : null, SESSION_NONCE);
714
- if (priv) {
715
- session.privateRooms.add(key);
716
- }
717
- else {
718
- session.privateRooms.delete(key);
719
- // Mode switch hygiene: drop any leftover private row so my_mentions'
720
- // per-room COALESCE stops using a baseline this session abandoned.
721
- // (Reached on explicit 'shared', or on omitted-cursor joins where the
722
- // session held no private mode -- a no-op there.)
723
- store.clearSessionCursor(target.id, id, SESSION_NONCE);
724
- }
725
- session.agentId = id;
1044
+ const { created } = store.joinRoom(target.id, agentId, epoch, {
1045
+ cursorStart: cursor_start,
1046
+ role,
1047
+ });
1048
+ // Session state mutates only AFTER the join commits: setting it first
1049
+ // would leave a failed join having repointed the still-valid previous
1050
+ // active room.
726
1051
  session.roomId = target.id;
727
- const cur = store.getCursor(target.id, id, cursorId());
1052
+ const cur = store.getCursor(target.id, agentId);
728
1053
  if (!cur) {
729
1054
  // The room was deleted by another process between our join and this
730
1055
  // read; same recovery contract as requireActive.
731
1056
  session.roomId = null;
732
1057
  return fail(`room "${target.name}" was deleted while joining; rejoin with join_room`);
733
1058
  }
1059
+ const persona = store.getPersona(agentId);
734
1060
  return ok({
735
- agent_id: id,
1061
+ agent_id: agentId,
1062
+ // The persona tuple rides on the JOIN response: the room now has a new
1063
+ // participant and the first thing everyone (including this runtime)
1064
+ // needs is WHICH model just walked in. Same field names as whoami.
1065
+ brand: persona?.brand ?? null,
1066
+ model: persona?.model ?? null,
1067
+ version: persona?.version ?? null,
1068
+ persona_description: persona?.description ?? null,
736
1069
  room_id: target.id,
737
1070
  room_name: target.name,
738
- description: target.description,
1071
+ // Named room_description, not `description`: a bare `description` next
1072
+ // to persona fields reads as the PERSONA's, and the two are different
1073
+ // strings owned by different things.
1074
+ room_description: target.description,
739
1075
  pinned: target.pinned,
740
- cursor: priv ? "private" : "shared",
1076
+ role: store.getRole(target.id, agentId),
1077
+ new_membership: created,
1078
+ ...(created && cursor_start === "latest"
1079
+ ? { cursor_start: "latest", note: "starting from now; backlog skipped" }
1080
+ : {}),
741
1081
  last_read_seq: cur.last_read_seq,
742
- unread: store.unreadCount(target.id, cur.last_read_seq, id),
1082
+ unread: store.unreadCount(target.id, cur.last_read_seq, agentId),
743
1083
  members: store.presentCount(target.id),
744
1084
  // Ready-to-run background poller invocation, shell-quoted for THIS
745
- // id (see the server instructions for its options and semantics).
746
- poller_cmd: pollerCmd(id, { session: SESSION_NONCE }),
747
- // Surface staleness at the session-start checkpoint, where it is seen
748
- // once without per-call noise. True => this server is running old code;
749
- // reconnect the MCP. See server_info for the latest commit.
750
- server_stale: buildStatus().stale,
1085
+ // persona and bound to the current epoch (see the server instructions).
1086
+ poller_cmd: pollerCmd(agentId, { epoch }),
1087
+ // Build identity rides with the command, at the session-start
1088
+ // checkpoint where it is seen once without per-call noise.
1089
+ ...handoffBuild(),
1090
+ });
1091
+ }
1092
+ catch (e) {
1093
+ return failFrom(e);
1094
+ }
1095
+ });
1096
+ server.registerTool("set_role", {
1097
+ title: "Set room role",
1098
+ description: "Set or CLEAR your role in a room. `role` is required: pass a NON-BLANK " +
1099
+ "string to set it, or null to clear it. Roles are room-local, so this " +
1100
+ "never touches your role in any other room. Omitting the field is " +
1101
+ "rejected rather than treated as a clear, because 'no change' and 'no " +
1102
+ "role' are different; an empty string is rejected for the same reason, " +
1103
+ "since null is the only way to say 'no role'.",
1104
+ inputSchema: z.object({
1105
+ role: z
1106
+ .string()
1107
+ .min(1)
1108
+ .max(200)
1109
+ .nullable()
1110
+ .describe("The new non-blank role, or null to clear it"),
1111
+ room: z
1112
+ .string()
1113
+ .min(1)
1114
+ .max(500)
1115
+ .optional()
1116
+ .describe("Set your role in a room you have JOINED (id or name) without " +
1117
+ "changing the active room. Omitted: the active room."),
1118
+ }).strict(),
1119
+ }, async ({ role, room }) => {
1120
+ try {
1121
+ touchSession();
1122
+ const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
1123
+ store.setRole(roomId, agentId, epoch, role);
1124
+ return ok({
1125
+ agent_id: agentId,
1126
+ room_id: roomId,
1127
+ room_name: roomName,
1128
+ role,
1129
+ cleared: role === null,
751
1130
  });
752
1131
  }
753
1132
  catch (e) {
754
- return fail(asMessage(e));
1133
+ return failFrom(e);
755
1134
  }
756
1135
  });
757
1136
  server.registerTool("leave_room", {
@@ -763,35 +1142,54 @@ server.registerTool("leave_room", {
763
1142
  }, async () => {
764
1143
  try {
765
1144
  touchSession();
766
- const { agentId, roomId } = requireActive();
767
- // Pass the process nonce so the leave is SESSION-scoped: it marks THIS
768
- // session's presence row left and recomputes identity presence, so a live
769
- // twin (shared or private) is never evicted. Presence is per-session for
770
- // every mode now, independent of the cursor nonce.
771
- const left = store.leaveRoom(roomId, agentId, SESSION_NONCE);
772
- // Keep the identity: the session is still this agent, and my_mentions
773
- // (memberships elsewhere) must keep working after leaving one room. The
774
- // room's cursor-mode entry also stays: its private position is preserved
775
- // for resume (matching leaveRoom keeping the session_markers row).
1145
+ const { agentId, epoch, roomId } = requireActive();
1146
+ const left = store.leaveRoom(roomId, agentId, epoch);
1147
+ // Keep the persona bound: the runtime is still this persona, and
1148
+ // my_mentions (memberships elsewhere) must keep working after leaving one
1149
+ // room. The membership row survives too, so its read position and role
1150
+ // are intact for a rejoin.
776
1151
  session.roomId = null;
777
1152
  return ok({ left, room_id: roomId, agent_id: agentId });
778
1153
  }
779
1154
  catch (e) {
780
- return fail(asMessage(e));
1155
+ return failFrom(e);
781
1156
  }
782
1157
  });
783
1158
  server.registerTool("whoami", {
784
1159
  title: "Who am I",
785
- description: "Report the current session identity, active room and unread count.",
1160
+ description: "Report this runtime's bound persona (agent_id, brand/model/version, " +
1161
+ "persona_description, runtime_epoch), its active room if any, and " +
1162
+ "`persona_lost` when a newer runtime has taken the persona over. The " +
1163
+ "PERSONA fields are reported whether or not a room is active, because " +
1164
+ "'which identity am I' is the question this tool exists to answer; the " +
1165
+ "room fields (room_id, room_name, room_description, role, last_read_seq, " +
1166
+ "unread) appear only while a room is active.",
786
1167
  inputSchema: z.object({}).strict(),
787
1168
  }, async () => {
788
1169
  try {
789
1170
  touchSession();
790
- if (session.agentId === null || session.roomId === null) {
791
- return ok({
792
- joined: false,
793
- ...(session.agentId !== null ? { agent_id: session.agentId } : {}),
794
- });
1171
+ if (session.agentId === null || session.epoch === null) {
1172
+ return ok({ bound: false, joined: false });
1173
+ }
1174
+ const agentId = session.agentId;
1175
+ const epoch = session.epoch;
1176
+ const persona = store.getPersona(agentId);
1177
+ // The persona block is UNCONDITIONAL once a persona is bound. Reporting
1178
+ // only joined:false and an id when no room is active hid the brand,
1179
+ // model, version, epoch and the loss state -- exactly the facts a runtime
1180
+ // that has just been fenced, or has just left a room, needs to see.
1181
+ const identity = {
1182
+ bound: true,
1183
+ agent_id: agentId,
1184
+ brand: persona?.brand ?? null,
1185
+ model: persona?.model ?? null,
1186
+ version: persona?.version ?? null,
1187
+ persona_description: persona?.description ?? null,
1188
+ runtime_epoch: epoch,
1189
+ ...lossDisclosure(agentId, epoch),
1190
+ };
1191
+ if (session.roomId === null) {
1192
+ return ok({ ...identity, joined: false });
795
1193
  }
796
1194
  const roomRow = store.getRoom(session.roomId);
797
1195
  if (!roomRow) {
@@ -799,36 +1197,39 @@ server.registerTool("whoami", {
799
1197
  // The identity survives.
800
1198
  session.roomId = null;
801
1199
  return ok({
1200
+ ...identity,
802
1201
  joined: false,
803
- agent_id: session.agentId,
804
1202
  note: "active room was deleted; rejoin",
805
1203
  });
806
1204
  }
807
- const cur = store.getCursor(session.roomId, session.agentId, cursorId());
1205
+ const cur = store.getCursor(session.roomId, agentId);
808
1206
  return ok({
1207
+ ...identity,
809
1208
  joined: true,
810
- agent_id: session.agentId,
811
1209
  room_id: session.roomId,
812
- room_name: roomRow?.name ?? null,
813
- cursor: session.privateRooms.has(privKey(session.roomId, session.agentId))
814
- ? "private"
815
- : "shared",
1210
+ room_name: roomRow.name,
1211
+ room_description: roomRow.description,
1212
+ role: store.getRole(session.roomId, agentId),
816
1213
  last_read_seq: cur?.last_read_seq ?? 0,
817
- unread: store.unreadCount(session.roomId, cur?.last_read_seq ?? 0, session.agentId),
1214
+ unread: store.unreadCount(session.roomId, cur?.last_read_seq ?? 0, agentId),
818
1215
  });
819
1216
  }
820
1217
  catch (e) {
821
- return fail(asMessage(e));
1218
+ return failFrom(e);
822
1219
  }
823
1220
  });
824
1221
  server.registerTool("list_agents", {
825
1222
  title: "List agents in room",
826
1223
  description: "List agents in the active room (up to `limit`; `total` rides along): " +
827
- "type/role/description, `last_read_seq` (read receipt: compare to a " +
1224
+ "brand/model/version, room-local `role`, `is_human`, description, " +
1225
+ "`last_read_seq` (read receipt: compare to a " +
828
1226
  "message seq), `last_seen`, `idle_seconds`, `present` (has not left), " +
829
1227
  "`active` (present and recently seen or carrying an unexpired wait lease), " +
830
1228
  "`watching` (an unexpired best-effort blocking catch_up wait lease; " +
831
- "not an acknowledgement or delivery guarantee). Long " +
1229
+ "not an acknowledgement or delivery guarantee). `last_seen`/`idle_seconds`/" +
1230
+ "`active` measure LISTENER recency -- an MCP call or an armed watcher's " +
1231
+ "two-minute heartbeat -- so they say a runtime is reachable, NOT that the " +
1232
+ "model is reading or able to wake; `watching` is the stronger claim. Long " +
832
1233
  "descriptions are listing previews (description_truncated). " +
833
1234
  "`next_after` present = more rows exist; page by passing it back as " +
834
1235
  "`after` (keyset paging, so a concurrent join cannot make you skip or " +
@@ -838,7 +1239,7 @@ server.registerTool("list_agents", {
838
1239
  .string()
839
1240
  .max(500)
840
1241
  .optional()
841
- .describe("Substring to match against id/type/role/description"),
1242
+ .describe("Substring to match against id/brand/model/version/role/description"),
842
1243
  active_within_minutes: z
843
1244
  .number()
844
1245
  .positive()
@@ -863,9 +1264,10 @@ server.registerTool("list_agents", {
863
1264
  }, async ({ filter, active_within_minutes, limit, after }) => {
864
1265
  try {
865
1266
  touchSession();
866
- const { roomId } = requireActive();
1267
+ const { agentId, epoch, roomId } = requireActive();
867
1268
  const { agents, total, next_after, size_trimmed } = store.listAgents(roomId, active_within_minutes ?? 5, filter, limit ?? 200, after);
868
1269
  return ok({
1270
+ ...lossDisclosure(agentId, epoch),
869
1271
  agents,
870
1272
  total,
871
1273
  ...(next_after !== undefined ? { next_after, truncated: true } : {}),
@@ -873,7 +1275,7 @@ server.registerTool("list_agents", {
873
1275
  });
874
1276
  }
875
1277
  catch (e) {
876
- return fail(asMessage(e));
1278
+ return failFrom(e);
877
1279
  }
878
1280
  });
879
1281
  server.registerTool("post_message", {
@@ -998,15 +1400,12 @@ server.registerTool("post_message", {
998
1400
  "assertion), not both");
999
1401
  }
1000
1402
  let agentId;
1403
+ let epoch;
1001
1404
  let roomId;
1002
1405
  let roomName;
1003
- let selector;
1004
1406
  if (room !== undefined) {
1005
1407
  // Explicit target: same membership rule as catch_up({room}).
1006
- if (session.agentId === null) {
1007
- return fail("join a room first with join_room to establish your identity");
1008
- }
1009
- agentId = session.agentId;
1408
+ ({ agentId, epoch } = requirePersona());
1010
1409
  const target = store.resolveRoom(room);
1011
1410
  if (!target) {
1012
1411
  return fail(`no room "${room}". Use list_rooms to see options.`);
@@ -1016,14 +1415,10 @@ server.registerTool("post_message", {
1016
1415
  }
1017
1416
  roomId = target.id;
1018
1417
  roomName = target.name;
1019
- selector = session.privateRooms.has(privKey(target.id, agentId))
1020
- ? SESSION_NONCE
1021
- : null;
1022
1418
  }
1023
1419
  else {
1024
- ({ agentId, roomId } = requireActive());
1420
+ ({ agentId, epoch, roomId } = requireActive());
1025
1421
  roomName = store.getRoom(roomId)?.name ?? null;
1026
- selector = cursorId();
1027
1422
  if (expected_room !== undefined) {
1028
1423
  const expect = store.resolveRoom(expected_room);
1029
1424
  if (!expect || expect.id !== roomId) {
@@ -1033,7 +1428,7 @@ server.registerTool("post_message", {
1033
1428
  }
1034
1429
  }
1035
1430
  }
1036
- touchCapturedRoom(roomId, agentId);
1431
+ touchCapturedRoom(roomId, agentId, epoch);
1037
1432
  const isText = typeof content === "string";
1038
1433
  // Validate structured strings DURING serialization, before JSON.stringify
1039
1434
  // escapes lone surrogates to harmless-looking ASCII. The store validates
@@ -1045,7 +1440,7 @@ server.registerTool("post_message", {
1045
1440
  return fail(`message body exceeds the ${MAX_MESSAGE_BODY_BYTES}-byte safety limit`);
1046
1441
  }
1047
1442
  const mentions = to && to.length > 0 ? dedupe(to) : null;
1048
- const res = store.postMessage(roomId, agentId, body, isText ? "text" : "json", mentions, reply_to_seq ?? null, supersedes_seq ?? null, selector, {
1443
+ const res = store.postMessage(roomId, agentId, body, isText ? "text" : "json", mentions, reply_to_seq ?? null, supersedes_seq ?? null, epoch, {
1049
1444
  ifLastReadSeq: if_last_read_seq ?? null,
1050
1445
  crossedPreviewChars: crossed_preview_chars,
1051
1446
  recipientActiveWithinMinutes: mentions ? 5 : undefined,
@@ -1104,6 +1499,12 @@ server.registerTool("post_message", {
1104
1499
  // facts. Room-local idleness is not a responsiveness prediction, so it
1105
1500
  // is mentioned only when older backlog already existed; seq-1 is the
1106
1501
  // pre-insert room maximum and costs no extra query.
1502
+ //
1503
+ // The idle arm measures last_seen, which an ARMED WATCHER refreshes every
1504
+ // two minutes. So this warning cannot fire for a seat with a live
1505
+ // watcher, however long the model behind it has been silent, and its
1506
+ // ABSENCE is not evidence anyone is reading. It is a one-directional
1507
+ // signal: when it fires, nothing has touched that seat at all.
1107
1508
  const delivery_warnings = recipients.flatMap((r) => {
1108
1509
  if (r.status === "unknown") {
1109
1510
  return [`${r.id}: never joined this room; the tag reaches no one`];
@@ -1122,8 +1523,9 @@ server.registerTool("post_message", {
1122
1523
  (r.idle_seconds ?? 0) >= DELIVERY_STALL_SECONDS &&
1123
1524
  priorMarkerBehind > 0) {
1124
1525
  return [
1125
- `${r.id}: no observed activity in this room for ${fmtIdle(r.idle_seconds ?? 0)}; ` +
1126
- `marker was ${priorMarkerBehind} seq behind before this post`,
1526
+ `${r.id}: no MCP call or watcher heartbeat in this room for ` +
1527
+ `${fmtIdle(r.idle_seconds ?? 0)}; marker was ${priorMarkerBehind} ` +
1528
+ `seq behind before this post`,
1127
1529
  ];
1128
1530
  }
1129
1531
  return [];
@@ -1155,7 +1557,7 @@ server.registerTool("post_message", {
1155
1557
  });
1156
1558
  }
1157
1559
  catch (e) {
1158
- return fail(asMessage(e));
1560
+ return failFrom(e);
1159
1561
  }
1160
1562
  });
1161
1563
  server.registerTool("catch_up", {
@@ -1221,30 +1623,12 @@ server.registerTool("catch_up", {
1221
1623
  "byte_limited:true = more remain, call again. An unusually " +
1222
1624
  "escape-heavy room name may require a larger value so the fixed " +
1223
1625
  "routing metadata plus one recoverable message stub can fit."),
1224
- mentions_me: z
1225
- .boolean()
1226
- .optional()
1227
- .describe("REMOVED in v0.6.0; use my_mentions. Passing it is an error."),
1228
- after_seq: z
1229
- .number()
1230
- .optional()
1231
- .describe("REMOVED in v0.6.0; my_mentions pages with after_id."),
1232
1626
  }).strict(),
1233
- }, async ({ room, wait_seconds, priority_only, limit, preview_chars, max_bytes, mentions_me, after_seq, }, extra) => {
1627
+ }, async ({ room, wait_seconds, priority_only, limit, preview_chars, max_bytes }, extra) => {
1234
1628
  const startedMs = Date.now();
1235
1629
  let heldWaitSlot = false;
1236
1630
  try {
1237
1631
  touchSession();
1238
- // Reject, never strip: a v0.5 caller sending mentions_me expected a
1239
- // non-advancing filtered peek; silently running an ADVANCING full sync
1240
- // instead would eat its unread backlog.
1241
- if (mentions_me !== undefined || after_seq !== undefined) {
1242
- return fail("mentions_me/after_seq were removed in v0.6.0: catch_up is now " +
1243
- "a full room sync by default and ADVANCES your marker. For messages " +
1244
- "directed at you use my_mentions (cross-room inbox, never advances " +
1245
- "markers, pages with after_id). This call was rejected instead of " +
1246
- "silently changing semantics.");
1247
- }
1248
1632
  if (priority_only === true && (wait_seconds ?? 0) > 0) {
1249
1633
  return fail("priority_only is lossy backlog triage and cannot be combined with " +
1250
1634
  "wait_seconds; run priority_only once, then use ordinary " +
@@ -1263,23 +1647,19 @@ server.registerTool("catch_up", {
1263
1647
  heldWaitSlot = true;
1264
1648
  }
1265
1649
  // --- Resolve and CAPTURE, all before the first await. Concurrent
1266
- // dispatch can mutate `session` (identity, active room, cursor modes)
1650
+ // dispatch can mutate `session` (the persona binding, the active room)
1267
1651
  // while a wait sleeps, so everything below runs off these captured
1268
- // values; the only deliberate re-read of session state is the
1269
- // cursor-mode epoch check (modeFlipped).
1652
+ // values.
1270
1653
  let agentId;
1654
+ let epoch;
1271
1655
  let roomId;
1272
1656
  let roomName;
1273
- let selector;
1274
1657
  if (room !== undefined) {
1275
- // Cross-room read: the ACTIVE room and its cursor mode stay untouched.
1276
- // Requires an existing membership (a never-joined room has no read
1277
- // position to advance); a soft-left room stays readable -- naming it
1278
- // is the intent to read it (parity with the scoped poller watch).
1279
- if (session.agentId === null) {
1280
- return fail("join a room first with join_room to establish your identity");
1281
- }
1282
- agentId = session.agentId;
1658
+ // Cross-room read: the ACTIVE room stays untouched. Requires an
1659
+ // existing membership (a never-joined room has no read position to
1660
+ // advance); a soft-left room stays readable -- naming it is the intent
1661
+ // to read it (parity with the scoped poller watch).
1662
+ ({ agentId, epoch } = requirePersona());
1283
1663
  const target = store.resolveRoom(room);
1284
1664
  if (!target) {
1285
1665
  return fail(`no room "${room}". Use list_rooms to see options.`);
@@ -1289,18 +1669,12 @@ server.registerTool("catch_up", {
1289
1669
  }
1290
1670
  roomId = target.id;
1291
1671
  roomName = target.name;
1292
- // Cursor selector for the TARGET room. cursorId() answers only for
1293
- // the active room, so it must not be used here.
1294
- selector = session.privateRooms.has(privKey(target.id, agentId))
1295
- ? SESSION_NONCE
1296
- : null;
1297
1672
  }
1298
1673
  else {
1299
- ({ agentId, roomId } = requireActive());
1674
+ ({ agentId, epoch, roomId } = requireActive());
1300
1675
  roomName = store.getRoom(roomId)?.name ?? null;
1301
- selector = cursorId();
1302
1676
  }
1303
- touchCapturedRoom(roomId, agentId);
1677
+ touchCapturedRoom(roomId, agentId, epoch);
1304
1678
  const signal = extra?.signal;
1305
1679
  // max_bytes bounds the COMPLETE JSON text returned to the MCP client,
1306
1680
  // not merely ChatStore.catchUp's inner object. v0.9 added routing fields
@@ -1335,31 +1709,15 @@ server.registerTool("catch_up", {
1335
1709
  ...result,
1336
1710
  ...extraFields,
1337
1711
  });
1338
- const advancingRead = (includeUnreadSummary) => store.catchUp(roomId, agentId, limit ?? 50, preview_chars, storeMaxBytes, selector,
1339
- // rooms_with_unread on an empty read; the RAW nonce, my_mentions
1340
- // style, so every room baselines off its own cursor mode.
1712
+ const advancingRead = (includeUnreadSummary) => store.catchUp(roomId, agentId, limit ?? 50, preview_chars, storeMaxBytes, epoch,
1713
+ // rooms_with_unread on an empty read.
1341
1714
  includeUnreadSummary
1342
- ? {
1343
- sessionId: SESSION_NONCE,
1344
- priorityOnly: priority_only === true,
1345
- }
1715
+ ? { priorityOnly: priority_only === true }
1346
1716
  : null);
1347
- // Cursor-mode epoch check: a private<->shared rejoin mid-wait re-bases
1348
- // the cursor (an explicit shared rejoin even deletes the private row),
1349
- // so an advancing read after a flip would consume from a DIFFERENT
1350
- // position than this call captured. Abort loudly instead.
1351
- const modeFlipped = () => (session.privateRooms.has(privKey(roomId, agentId))
1352
- ? SESSION_NONCE
1353
- : null) !== selector;
1354
- const sessionChangedResult = () => respond({
1355
- messages: [],
1356
- session_changed: true,
1357
- call_again: true,
1358
- waited_ms: Date.now() - startedMs,
1359
- note: "this room's cursor mode flipped (private/shared rejoin) " +
1360
- "mid-wait; nothing was read or advanced -- call catch_up again " +
1361
- "to read from the current cursor",
1362
- });
1717
+ // A takeover is the only way this call's authority can change under it.
1718
+ // Every advancingRead re-verifies the captured epoch inside its own
1719
+ // transaction, so a fenced read throws persona_lost rather than consuming
1720
+ // from a position this call did not capture.
1363
1721
  const roomDeletedResult = (duringWait = false) => {
1364
1722
  // A delete racing the active-room read invalidates that active route.
1365
1723
  // Do not clear a different room selected by a concurrent join, nor the
@@ -1391,14 +1749,16 @@ server.registerTool("catch_up", {
1391
1749
  // --- Blocking wait: abort-aware timer, non-advancing read-only probe
1392
1750
  // with catchUp's exact predicate, advancing read only on a hit.
1393
1751
  const deadlineMs = startedMs + waitSeconds * 1000;
1394
- // One lease token per CALL, not per process. Concurrent waits from one
1395
- // MCP process must not overwrite and then delete each other's row.
1396
- const waitLeaseId = `${SESSION_NONCE}:${randomUUID()}`;
1397
1752
  // Presence lease: when its best-effort write succeeds, `watching`
1398
1753
  // records that this call was open. TTL bounds a hard-kill ghost; a lease
1399
1754
  // failure must not break the wait (the probe surfaces a deleted room).
1755
+ // The lease is keyed by persona and carries the epoch CAPTURED here, so
1756
+ // the cleanup in the finally below can only ever delete this call's own
1757
+ // row -- never one a takeover wrote after fencing this call out.
1758
+ const leaseKey = `${roomKey(roomId, agentId)}\u0000${epoch}`;
1759
+ openWaits.set(leaseKey, (openWaits.get(leaseKey) ?? 0) + 1);
1400
1760
  try {
1401
- store.beginWaitLease(roomId, agentId, waitLeaseId, waitSeconds + WAIT_LEASE_GRACE_SECONDS);
1761
+ store.beginWaitLease(roomId, agentId, epoch, waitSeconds + WAIT_LEASE_GRACE_SECONDS);
1402
1762
  }
1403
1763
  catch { }
1404
1764
  try {
@@ -1413,12 +1773,19 @@ server.registerTool("catch_up", {
1413
1773
  break;
1414
1774
  // Self-throttled heartbeat: a genuinely-waiting agent reads as
1415
1775
  // `active` to peers instead of indistinguishable from a dormant one.
1416
- touchCapturedRoom(roomId, agentId);
1776
+ touchCapturedRoom(roomId, agentId, epoch);
1417
1777
  let unread;
1418
1778
  try {
1419
- unread = store.unreadProbe(roomId, agentId, selector);
1779
+ // Epoch-fenced: a takeover surfaces here, within one probe
1780
+ // interval, instead of waiting for traffic or the deadline.
1781
+ unread = store.unreadProbe(roomId, agentId, epoch);
1420
1782
  }
1421
1783
  catch (e) {
1784
+ // A lost persona is NOT a deleted room. Rethrow it before the
1785
+ // room-existence fallback, or a takeover would be reported as
1786
+ // "the room was deleted".
1787
+ if (e instanceof PersonaLostError)
1788
+ throw e;
1422
1789
  if (!store.getRoom(roomId))
1423
1790
  return roomDeletedResult(true);
1424
1791
  throw e;
@@ -1427,8 +1794,6 @@ server.registerTool("catch_up", {
1427
1794
  continue;
1428
1795
  if (signal?.aborted)
1429
1796
  return respond({ aborted: true });
1430
- if (modeFlipped())
1431
- return sessionChangedResult();
1432
1797
  let hit;
1433
1798
  try {
1434
1799
  // A probe/read race may make this empty too; its result is discarded,
@@ -1445,13 +1810,11 @@ server.registerTool("catch_up", {
1445
1810
  if (hit.messages.length > 0) {
1446
1811
  return respond(hit, { waited_ms: Date.now() - startedMs });
1447
1812
  }
1448
- // A shared twin consumed the page between probe and read: keep
1449
- // waiting on the (now advanced) cursor, never refire stale rows.
1813
+ // The cursor advanced between probe and read (an own-only
1814
+ // normalization): keep waiting, never refire stale rows.
1450
1815
  }
1451
1816
  if (signal?.aborted)
1452
1817
  return respond({ aborted: true });
1453
- if (modeFlipped())
1454
- return sessionChangedResult();
1455
1818
  let last;
1456
1819
  try {
1457
1820
  last = advancingRead(true);
@@ -1469,14 +1832,21 @@ server.registerTool("catch_up", {
1469
1832
  });
1470
1833
  }
1471
1834
  finally {
1472
- try {
1473
- store.endWaitLease(roomId, agentId, waitLeaseId);
1835
+ const remaining = (openWaits.get(leaseKey) ?? 1) - 1;
1836
+ if (remaining > 0) {
1837
+ openWaits.set(leaseKey, remaining);
1838
+ }
1839
+ else {
1840
+ openWaits.delete(leaseKey);
1841
+ try {
1842
+ store.endWaitLease(roomId, agentId, epoch);
1843
+ }
1844
+ catch { }
1474
1845
  }
1475
- catch { }
1476
1846
  }
1477
1847
  }
1478
1848
  catch (e) {
1479
- return fail(asMessage(e));
1849
+ return failFrom(e);
1480
1850
  }
1481
1851
  finally {
1482
1852
  if (heldWaitSlot)
@@ -1528,16 +1898,22 @@ server.registerTool("my_mentions", {
1528
1898
  }, async ({ limit, preview_chars, max_bytes, after_id }) => {
1529
1899
  try {
1530
1900
  touchSession();
1531
- if (session.agentId === null) {
1532
- return fail("join a room first with join_room to establish your identity");
1533
- }
1534
- // Always pass the nonce: the store's per-room COALESCE uses this
1535
- // session's private cursor exactly where one exists (shared joins clear
1536
- // theirs), so each room gets ITS OWN mode rather than the active room's.
1537
- return ok(store.myMentions(session.agentId, limit ?? 50, preview_chars, max_bytes, SESSION_NONCE, after_id ?? 0));
1901
+ // my_mentions needs a PERSONA but no active room: it reads across every
1902
+ // room the persona is present in, which is exactly what makes it usable
1903
+ // after a leave. So the remedy is binding, not joining.
1904
+ const { agentId, epoch } = requirePersona();
1905
+ // Reserve the disclosure's exact serialized cost BEFORE the bounded read,
1906
+ // so a caller's max_bytes still holds on the fenced path.
1907
+ const lost = lossDisclosure(agentId, epoch);
1908
+ const requested = max_bytes ?? DEFAULT_MAX_BYTES;
1909
+ const reserve = disclosureReserve(lost);
1910
+ return ok({
1911
+ ...lost,
1912
+ ...store.myMentions(agentId, limit ?? 50, preview_chars, Math.max(MIN_CATCH_UP_RESULT_BUDGET, requested - reserve), after_id ?? 0),
1913
+ });
1538
1914
  }
1539
1915
  catch (e) {
1540
- return fail(asMessage(e));
1916
+ return failFrom(e);
1541
1917
  }
1542
1918
  });
1543
1919
  server.registerTool("pending_work", {
@@ -1546,8 +1922,7 @@ server.registerTool("pending_work", {
1546
1922
  "at them (mentions or replies), one row per agent+room, oldest pending " +
1547
1923
  "first with `oldest_seq`/`oldest_unix` and per-room `idle_seconds`. " +
1548
1924
  "For a supervisor deciding whom to wake or nudge; my_mentions answers " +
1549
- "this only for yourself. Read markers are identity-level, so a lagging " +
1550
- "private session can be further behind than shown. `next_after` means " +
1925
+ "this only for yourself. `next_after` means " +
1551
1926
  "more rows exist; pass it back as `after`. Pending rows are live, so " +
1552
1927
  "dedupe agent_id+room_id during a paged sweep.",
1553
1928
  inputSchema: z.object({
@@ -1585,7 +1960,7 @@ server.registerTool("pending_work", {
1585
1960
  });
1586
1961
  }
1587
1962
  catch (e) {
1588
- return fail(asMessage(e));
1963
+ return failFrom(e);
1589
1964
  }
1590
1965
  });
1591
1966
  server.registerTool("wait_for_messages", {
@@ -1628,20 +2003,22 @@ server.registerTool("wait_for_messages", {
1628
2003
  }, async ({ room, mentions_only, timeout, interval }) => {
1629
2004
  try {
1630
2005
  touchSession();
1631
- const agentId = session.agentId;
1632
- if (agentId === null) {
1633
- return fail("join a room first with join_room to establish your identity, then call this again");
2006
+ const { agentId, epoch } = requirePersona();
2007
+ // Check the binding BEFORE handing out a command. The poller keeps its
2008
+ // own per-probe epoch check for a takeover that lands after generation,
2009
+ // but a command minted for an already-dead epoch is a command that can
2010
+ // only ever exit stale_binding, and returning one sends the caller off to
2011
+ // arm a watcher instead of telling it the thing it needs to know.
2012
+ const current = store.currentEpoch(agentId);
2013
+ if (current !== epoch) {
2014
+ return failFrom(new PersonaLostError(agentId, epoch, current));
1634
2015
  }
1635
2016
  let roomArg;
1636
2017
  if (room !== undefined) {
1637
- // Resolve to an id and require only that a membership row EXISTS -- not
1638
- // that it is still present. A scoped --room probe (check.ts) baselines
1639
- // off the room's preserved read marker regardless of left_at, and the
1640
- // poller contract deliberately supports watching a room after
1641
- // soft-leaving it ("naming the room is the intent to watch it").
1642
- // Rejecting soft-left here contradicted that and blocked a valid watch.
1643
- // A never-joined room has no marker to baseline from, so that still
1644
- // fails with the real remedy.
2018
+ // PRESENT membership required, not merely an existing row. A watcher on
2019
+ // a room this persona has left would deliver traffic peers can see it
2020
+ // is not receiving -- delivery and visibility must read the same
2021
+ // membership state. A never-joined room fails with its own remedy.
1645
2022
  const target = store.resolveRoom(room);
1646
2023
  if (!target) {
1647
2024
  return fail(`no room "${room}". Use list_rooms to see options, or omit room to watch all rooms you are in.`);
@@ -1650,6 +2027,12 @@ server.registerTool("wait_for_messages", {
1650
2027
  if (!m) {
1651
2028
  return fail(`you have never joined room "${target.name}", so there is no read position to watch from; join_room it first, then call wait_for_messages`);
1652
2029
  }
2030
+ if (m.left_at !== null) {
2031
+ return fail(`you have LEFT room "${target.name}", so a watcher on it would ` +
2032
+ `report traffic to a seat peers can see is empty. Call join_room ` +
2033
+ `to rejoin -- your read position and role are preserved -- then ` +
2034
+ `use the poller command it returns.`);
2035
+ }
1653
2036
  roomArg = String(target.id);
1654
2037
  }
1655
2038
  else if (store.presentRoomCount(agentId) === 0) {
@@ -1660,7 +2043,7 @@ server.registerTool("wait_for_messages", {
1660
2043
  const command = pollerCmd(agentId, {
1661
2044
  room: roomArg,
1662
2045
  mentionsOnly: mentions_only,
1663
- session: SESSION_NONCE,
2046
+ epoch,
1664
2047
  timeoutSec: timeout,
1665
2048
  intervalSec: interval,
1666
2049
  });
@@ -1679,10 +2062,11 @@ server.registerTool("wait_for_messages", {
1679
2062
  },
1680
2063
  baselined: false,
1681
2064
  single_process: true,
2065
+ ...handoffBuild(),
1682
2066
  });
1683
2067
  }
1684
2068
  catch (e) {
1685
- return fail(asMessage(e));
2069
+ return failFrom(e);
1686
2070
  }
1687
2071
  });
1688
2072
  server.registerTool("read_history", {
@@ -1706,10 +2090,6 @@ server.registerTool("read_history", {
1706
2090
  .positive()
1707
2091
  .optional()
1708
2092
  .describe("Return messages older than this seq (for pagination)"),
1709
- mentions_me: z
1710
- .boolean()
1711
- .optional()
1712
- .describe("REMOVED in v0.6.0; use my_mentions. Passing it is an error."),
1713
2093
  preview_chars: z
1714
2094
  .number()
1715
2095
  .int()
@@ -1727,19 +2107,19 @@ server.registerTool("read_history", {
1727
2107
  .describe("Serialized-size budget per page (default 100000); " +
1728
2108
  "byte_limited:true = trimmed, continue with before_seq."),
1729
2109
  }).strict(),
1730
- }, async ({ limit, before_seq, mentions_me, preview_chars, max_bytes }) => {
2110
+ }, async ({ limit, before_seq, preview_chars, max_bytes }) => {
1731
2111
  try {
1732
2112
  touchSession();
1733
- if (mentions_me !== undefined) {
1734
- return fail("mentions_me was removed from read_history in v0.6.0; use " +
1735
- "my_mentions for the cross-room directed inbox or search_messages " +
1736
- "to find topics.");
1737
- }
1738
- const { roomId } = requireActive();
1739
- return ok(store.readHistory(roomId, limit ?? 50, before_seq, preview_chars, max_bytes));
2113
+ const { agentId, epoch, roomId } = requireActive();
2114
+ const lost = lossDisclosure(agentId, epoch);
2115
+ const requested = max_bytes ?? DEFAULT_MAX_BYTES;
2116
+ return ok({
2117
+ ...lost,
2118
+ ...store.readHistory(roomId, limit ?? 50, before_seq, preview_chars, Math.max(MIN_CATCH_UP_RESULT_BUDGET, requested - disclosureReserve(lost))),
2119
+ });
1740
2120
  }
1741
2121
  catch (e) {
1742
- return fail(asMessage(e));
2122
+ return failFrom(e);
1743
2123
  }
1744
2124
  });
1745
2125
  server.registerTool("mark_read", {
@@ -1759,11 +2139,11 @@ server.registerTool("mark_read", {
1759
2139
  }, async ({ seq }) => {
1760
2140
  try {
1761
2141
  touchSession();
1762
- const { agentId, roomId } = requireActive();
1763
- return ok(store.markRead(roomId, agentId, seq, cursorId()));
2142
+ const { agentId, epoch, roomId } = requireActive();
2143
+ return ok(store.markRead(roomId, agentId, epoch, seq));
1764
2144
  }
1765
2145
  catch (e) {
1766
- return fail(asMessage(e));
2146
+ return failFrom(e);
1767
2147
  }
1768
2148
  });
1769
2149
  server.registerTool("get_message", {
@@ -1803,18 +2183,16 @@ server.registerTool("get_message", {
1803
2183
  }, async ({ room, seq, offset, max_chars }) => {
1804
2184
  try {
1805
2185
  touchSession();
1806
- const { roomId, roomName } = resolveJoinedRoom(room);
1807
- if (session.agentId !== null) {
1808
- touchCapturedRoom(roomId, session.agentId);
1809
- }
2186
+ const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2187
+ touchCapturedRoom(roomId, agentId, epoch);
1810
2188
  const msg = store.getMessage(roomId, seq, offset ?? 0, max_chars);
1811
2189
  if (!msg) {
1812
2190
  return fail(`no message ${seq} in room "${roomName ?? roomId}"`);
1813
2191
  }
1814
- return ok(msg);
2192
+ return ok({ ...lossDisclosure(agentId, epoch), ...msg });
1815
2193
  }
1816
2194
  catch (e) {
1817
- return fail(asMessage(e));
2195
+ return failFrom(e);
1818
2196
  }
1819
2197
  });
1820
2198
  server.registerTool("get_thread", {
@@ -1852,18 +2230,16 @@ server.registerTool("get_thread", {
1852
2230
  }, async ({ room, seq, max_depth, preview_chars }) => {
1853
2231
  try {
1854
2232
  touchSession();
1855
- const { roomId, roomName } = resolveJoinedRoom(room);
1856
- if (session.agentId !== null) {
1857
- touchCapturedRoom(roomId, session.agentId);
1858
- }
2233
+ const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2234
+ touchCapturedRoom(roomId, agentId, epoch);
1859
2235
  const thread = store.getThread(roomId, seq, max_depth ?? 3, preview_chars);
1860
2236
  if (!thread) {
1861
2237
  return fail(`no message ${seq} in room "${roomName ?? roomId}"`);
1862
2238
  }
1863
- return ok(thread);
2239
+ return ok({ ...lossDisclosure(agentId, epoch), ...thread });
1864
2240
  }
1865
2241
  catch (e) {
1866
- return fail(asMessage(e));
2242
+ return failFrom(e);
1867
2243
  }
1868
2244
  });
1869
2245
  server.registerTool("set_room_intro", {
@@ -1879,13 +2255,13 @@ server.registerTool("set_room_intro", {
1879
2255
  }, async ({ text }) => {
1880
2256
  try {
1881
2257
  touchSession();
1882
- const { roomId } = requireActive();
2258
+ const { agentId, epoch, roomId } = requireActive();
1883
2259
  const value = text.length > 0 ? text : null;
1884
- store.setPinned(roomId, value);
2260
+ store.setPinned(roomId, agentId, epoch, value);
1885
2261
  return ok({ room_id: roomId, pinned: value });
1886
2262
  }
1887
2263
  catch (e) {
1888
- return fail(asMessage(e));
2264
+ return failFrom(e);
1889
2265
  }
1890
2266
  });
1891
2267
  server.registerTool("search_messages", {
@@ -1915,11 +2291,14 @@ server.registerTool("search_messages", {
1915
2291
  }, async ({ query, limit, offset }) => {
1916
2292
  try {
1917
2293
  touchSession();
1918
- const { roomId } = requireActive();
1919
- return ok(store.searchMessages(roomId, query, limit ?? 20, offset ?? 0));
2294
+ const { agentId, epoch, roomId } = requireActive();
2295
+ return ok({
2296
+ ...lossDisclosure(agentId, epoch),
2297
+ ...store.searchMessages(roomId, query, limit ?? 20, offset ?? 0),
2298
+ });
1920
2299
  }
1921
2300
  catch (e) {
1922
- return fail(asMessage(e));
2301
+ return failFrom(e);
1923
2302
  }
1924
2303
  });
1925
2304
  server.registerTool("claim", {
@@ -1960,16 +2339,16 @@ server.registerTool("claim", {
1960
2339
  }, async ({ room, key, ttl_seconds, note }) => {
1961
2340
  try {
1962
2341
  touchSession();
1963
- const { agentId, roomId, roomName } = resolveJoinedRoom(room);
1964
- touchCapturedRoom(roomId, agentId);
2342
+ const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2343
+ touchCapturedRoom(roomId, agentId, epoch);
1965
2344
  return ok({
1966
2345
  room_id: roomId,
1967
2346
  room_name: roomName,
1968
- ...store.claimResource(roomId, key, agentId, ttl_seconds ?? 900, note ?? null),
2347
+ ...store.claimResource(roomId, key, agentId, epoch, ttl_seconds ?? 900, note ?? null),
1969
2348
  });
1970
2349
  }
1971
2350
  catch (e) {
1972
- return fail(asMessage(e));
2351
+ return failFrom(e);
1973
2352
  }
1974
2353
  });
1975
2354
  server.registerTool("release_claim", {
@@ -1989,16 +2368,16 @@ server.registerTool("release_claim", {
1989
2368
  }, async ({ room, key }) => {
1990
2369
  try {
1991
2370
  touchSession();
1992
- const { agentId, roomId, roomName } = resolveJoinedRoom(room);
1993
- touchCapturedRoom(roomId, agentId);
2371
+ const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2372
+ touchCapturedRoom(roomId, agentId, epoch);
1994
2373
  return ok({
1995
2374
  room_id: roomId,
1996
2375
  room_name: roomName,
1997
- ...store.releaseClaim(roomId, key, agentId),
2376
+ ...store.releaseClaim(roomId, key, agentId, epoch),
1998
2377
  });
1999
2378
  }
2000
2379
  catch (e) {
2001
- return fail(asMessage(e));
2380
+ return failFrom(e);
2002
2381
  }
2003
2382
  });
2004
2383
  server.registerTool("list_claims", {
@@ -2037,10 +2416,11 @@ server.registerTool("list_claims", {
2037
2416
  }, async ({ room, limit, after_key }) => {
2038
2417
  try {
2039
2418
  touchSession();
2040
- const { agentId, roomId, roomName } = resolveJoinedRoom(room);
2041
- touchCapturedRoom(roomId, agentId);
2419
+ const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2420
+ touchCapturedRoom(roomId, agentId, epoch);
2042
2421
  const { claims, total, next_key, size_trimmed } = store.listClaims(roomId, limit ?? 200, after_key ?? "");
2043
2422
  return ok({
2423
+ ...lossDisclosure(agentId, epoch),
2044
2424
  room_id: roomId,
2045
2425
  room_name: roomName,
2046
2426
  claims,
@@ -2050,7 +2430,7 @@ server.registerTool("list_claims", {
2050
2430
  });
2051
2431
  }
2052
2432
  catch (e) {
2053
- return fail(asMessage(e));
2433
+ return failFrom(e);
2054
2434
  }
2055
2435
  });
2056
2436
  server.registerTool("prune_messages", {
@@ -2059,8 +2439,8 @@ server.registerTool("prune_messages", {
2059
2439
  "`keep_last` (kept seqs and future numbering unchanged). Destructive, " +
2060
2440
  "not reversible. By default REFUSES (refused:true with " +
2061
2441
  "would_delete_unread/min_read_seq) if any non-author member has not " +
2062
- "read a doomed message yet, including members that left and lagging " +
2063
- "private session cursors; force=true prunes anyway.",
2442
+ "read a doomed message yet, including members that left; force=true " +
2443
+ "prunes anyway.",
2064
2444
  inputSchema: z.object({
2065
2445
  keep_last: z
2066
2446
  .number()
@@ -2075,14 +2455,14 @@ server.registerTool("prune_messages", {
2075
2455
  }, async ({ keep_last, force }) => {
2076
2456
  try {
2077
2457
  touchSession();
2078
- const { roomId } = requireActive();
2458
+ const { agentId, epoch, roomId } = requireActive();
2079
2459
  return ok({
2080
2460
  room_id: roomId,
2081
- ...store.pruneMessages(roomId, keep_last, force ?? false),
2461
+ ...store.pruneMessages(roomId, agentId, epoch, keep_last, force ?? false),
2082
2462
  });
2083
2463
  }
2084
2464
  catch (e) {
2085
- return fail(asMessage(e));
2465
+ return failFrom(e);
2086
2466
  }
2087
2467
  });
2088
2468
  server.registerTool("delete_room", {
@@ -2100,72 +2480,111 @@ server.registerTool("delete_room", {
2100
2480
  }, async ({ room, confirm }) => {
2101
2481
  try {
2102
2482
  touchSession();
2483
+ const { agentId, epoch } = requirePersona();
2103
2484
  const target = store.resolveRoom(room);
2104
2485
  if (!target)
2105
2486
  return fail(`no room "${room}"`);
2106
2487
  if (confirm !== true) {
2107
2488
  return fail(`pass confirm:true to delete room ${target.id} ("${target.name}")`);
2108
2489
  }
2109
- const result = store.deleteRoom(target.id);
2110
- // Drop every identity's private-mode entry for the dead room.
2111
- // (Deleting the current element during Set iteration is well-defined.)
2112
- for (const k of session.privateRooms) {
2113
- if (k.startsWith(`${target.id}\u0000`))
2114
- session.privateRooms.delete(k);
2115
- }
2490
+ const result = store.deleteRoom(target.id, agentId, epoch);
2116
2491
  if (session.roomId === target.id) {
2117
2492
  session.roomId = null; // identity survives; only the room is gone
2118
2493
  }
2119
2494
  return ok({ deleted_room: target.id, name: target.name, ...result });
2120
2495
  }
2121
2496
  catch (e) {
2122
- return fail(asMessage(e));
2497
+ return failFrom(e);
2123
2498
  }
2124
2499
  });
2125
- // Readable identity generation for agents that omit agent_id. Two short word
2126
- // lists give ~676 base combinations; a hex suffix (then a UUID fallback)
2127
- // guarantees a free id even under collision. The id is claimed atomically via
2128
- // tryCreateAgent so concurrent assigners cannot land on the same identity.
2129
- const ID_ADJECTIVES = [
2500
+ // Resume-word vocabulary. Two 26-entry word lists plus a 16-bit number give
2501
+ // 26 * 26 * 65536 = ~44 million combinations, which is ample for the ONLY job
2502
+ // this word has: making it improbable that an operator who pastes a
2503
+ // wrong-but-plausible word lands on a persona that is not theirs. It is not a
2504
+ // secret, not a password, and is stored in plain text -- see create_persona's
2505
+ // description for the explicit threat model. Every part is drawn from
2506
+ // randomBytes (a CSPRNG) rather than Math.random, so two personas created in
2507
+ // the same millisecond do not collide.
2508
+ const WORD_ADJECTIVES = [
2130
2509
  "amber", "brisk", "calm", "clever", "cobalt", "copper", "deft", "eager",
2131
2510
  "fern", "gilded", "hardy", "ivory", "jade", "keen", "lucid", "mellow",
2132
2511
  "nimble", "olive", "prime", "quiet", "rapid", "sable", "teal", "umber",
2133
2512
  "vivid", "warm",
2134
2513
  ];
2135
- const ID_NOUNS = [
2514
+ const WORD_NOUNS = [
2136
2515
  "otter", "falcon", "cedar", "harbor", "lynx", "maple", "comet", "delta",
2137
2516
  "ember", "fjord", "grove", "heron", "inlet", "kite", "larch", "mesa",
2138
2517
  "nimbus", "onyx", "pike", "quartz", "ridge", "summit", "tundra", "vale",
2139
2518
  "willow", "yarrow",
2140
2519
  ];
2520
+ /** Uniform index into xs, drawn from CSPRNG bytes with rejection sampling so a
2521
+ * non-multiple list length cannot bias the draw. */
2141
2522
  function pick(xs) {
2142
- return xs[Math.floor(Math.random() * xs.length)];
2523
+ const limit = Math.floor(256 / xs.length) * xs.length;
2524
+ for (;;) {
2525
+ const b = randomBytes(1)[0];
2526
+ if (b < limit)
2527
+ return xs[b % xs.length];
2528
+ }
2143
2529
  }
2144
- /** Assign and atomically claim a readable, collision-free agent id. */
2145
- function assignReadableId(type, role, description) {
2146
- for (let i = 0; i < 30; i++) {
2147
- const base = `${pick(ID_ADJECTIVES)}-${pick(ID_NOUNS)}`;
2148
- // After a run of plain-name misses, widen the space with a short suffix.
2149
- const id = i < 12 ? base : `${base}-${randomUUID().slice(0, 4)}`;
2150
- if (store.tryCreateAgent(id, type, role, description))
2151
- return id;
2152
- }
2153
- // Last resort: keep drawing until an id is actually CLAIMED. Returning an
2154
- // unclaimed id here silently adopted an EXISTING identity (shared read
2155
- // markers and claims); ids are self-asserted, so a collision is improbable
2156
- // but not impossible.
2157
- for (let i = 0; i < 30; i++) {
2158
- const id = `agent-${randomUUID().slice(0, 8)}`;
2159
- if (store.tryCreateAgent(id, type, role, description))
2160
- return id;
2161
- }
2162
- throw new Error("could not allocate a generated agent id; pass an explicit agent_id");
2530
+ function makeResumeWord() {
2531
+ return `${pick(WORD_ADJECTIVES)}-${pick(WORD_NOUNS)}-${randomBytes(2).readUInt16BE(0)}`;
2532
+ }
2533
+ /**
2534
+ * Normalize one component of a canonical persona id: lowercase, non-alphanumeric
2535
+ * runs collapsed to single hyphens, trimmed. "Claude Opus 4.5" -> "claude-opus-4-5".
2536
+ * Returns "" when nothing survives, which the caller rejects.
2537
+ */
2538
+ function slug(s) {
2539
+ return s
2540
+ .toLowerCase()
2541
+ .replace(/[^a-z0-9]+/g, "-")
2542
+ .replace(/^-+|-+$/g, "");
2543
+ }
2544
+ /**
2545
+ * Build and atomically claim the canonical persona id
2546
+ * `brand-model-vversion-shorttoken`.
2547
+ *
2548
+ * The short token is what makes two personas with the SAME brand/model/version
2549
+ * distinguishable -- a common case, since two runtimes of one model routinely
2550
+ * work in one room. The claim is atomic (INSERT ... ON CONFLICT DO NOTHING) so
2551
+ * two processes drawing the same token cannot both believe they own it and
2552
+ * collapse onto a single row, sharing its read marker, memberships, and claims.
2553
+ */
2554
+ function createPersona(brand, model, version, description) {
2555
+ const parts = [slug(brand), slug(model), `v${slug(version)}`];
2556
+ if (parts[0] === "" || parts[1] === "" || parts[2] === "v") {
2557
+ throw new Error("brand, model, and version must each contain at least one letter or digit");
2558
+ }
2559
+ const base = parts.join("-");
2560
+ // One fixed token width, so a persona id has one shape and the length check
2561
+ // below is exact rather than a bound that holds only while collisions stay
2562
+ // rare.
2563
+ const TOKEN_CHARS = 6;
2564
+ if (base.length + 1 + TOKEN_CHARS > MAX_AGENT_ID_CHARS) {
2565
+ throw new Error(`brand/model/version are too long: the canonical persona id ` +
2566
+ `"${base}-<token>" needs ${base.length + 1 + TOKEN_CHARS} characters ` +
2567
+ `and the limit is ${MAX_AGENT_ID_CHARS}. Shorten them by at least ` +
2568
+ `${base.length + 1 + TOKEN_CHARS - MAX_AGENT_ID_CHARS} characters ` +
2569
+ `(punctuation collapses to single hyphens, so it still counts).`);
2570
+ }
2571
+ const resumeWord = makeResumeWord();
2572
+ for (let i = 0; i < 40; i++) {
2573
+ const token = randomBytes(TOKEN_CHARS / 2).toString("hex");
2574
+ const id = `${base}-${token}`;
2575
+ if (store.tryCreatePersona({ id, brand, model, version, resumeWord, description })) {
2576
+ return { id, resumeWord };
2577
+ }
2578
+ }
2579
+ throw new Error("could not allocate a persona id after 40 attempts; retry create_persona");
2163
2580
  }
2164
2581
  function dedupe(xs) {
2165
2582
  return [...new Set(xs)];
2166
2583
  }
2167
2584
  /** Room-local inactivity threshold for a factual pre-existing-backlog warning.
2168
- * Chosen well past the 5-minute `active` window to avoid routine idle noise. */
2585
+ * Chosen well past the 5-minute `active` window to avoid routine idle noise.
2586
+ * Measures last_seen, so it detects an UNTOUCHED seat, not a silent model: an
2587
+ * armed watcher's two-minute heartbeat keeps a seat below this forever. */
2169
2588
  const DELIVERY_STALL_SECONDS = 1800;
2170
2589
  /** Human-readable idle duration for delivery warnings ("2h05m", "45m", "90s"). */
2171
2590
  function fmtIdle(seconds) {
@@ -2196,7 +2615,7 @@ function shutdown(code, message) {
2196
2615
  // their finally blocks (notably wait-lease deletion) before closing the
2197
2616
  // shared store, so EOF cannot leave a wait alive or advance afterward.
2198
2617
  await server.close().catch(() => undefined);
2199
- await Promise.allSettled([...activeToolRequests]);
2618
+ await Promise.allSettled(activeToolRequests);
2200
2619
  try {
2201
2620
  store.close();
2202
2621
  }