multi-agent-collaboration-mcp 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 { randomBytes } from "node:crypto";
5
+ import { randomBytes, randomUUID } 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, 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";
11
+ import { ChatStore, ConnectionNonceCollisionError, 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;
@@ -45,23 +45,18 @@ function pollerCmd(agentId, opts = {}) {
45
45
  // accumulating until a long timeout. Direct CLI commands can omit
46
46
  // --owner-pid when independent lifetime is intentional.
47
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.
48
+ // --owner-pid ALONE is what marks a watcher as GENERATED rather than
49
+ // hand-run, and only a generated watcher refreshes last_seen. A watcher
50
+ // without it stays strictly read-only.
50
51
  cmd += ` --owner-pid ${shq(String(process.pid))}`;
51
52
  if (opts.room !== undefined)
52
53
  cmd += ` --room ${shq(opts.room)}`;
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))}`;
64
- }
54
+ // No tenure argument is baked in. The watcher re-reads the identity's live
55
+ // state on every probe, and an agent_id has exactly one tenure, so there is
56
+ // nothing a captured ordinal could add. Every probe likewise resolves the
57
+ // CURRENT read cursor: never bake a point-in-time --since baseline into a
58
+ // restartable command, because once crossed it fires forever.
59
+ //
65
60
  // Baked-in loop knobs, so callers stop hand-editing the command string
66
61
  // (the historical -32602 friction: the values LOOK like tool params).
67
62
  if (opts.timeoutSec !== undefined) {
@@ -101,7 +96,7 @@ const WAIT_CAP_SECONDS = configuredWaitCapSeconds();
101
96
  // Validate process-level configuration before opening the shared
102
97
  // database. A typo must fail without mutating production state first.
103
98
  const store = new ChatStore();
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.`;
99
+ const INSTRUCTIONS = `Agent Chat is a local SQLite ledger. Start identify_persona -> list_rooms or create_room -> join_room -> catch_up. identify_persona takes your actual brand, model, and complete version string (version is TEXT: keep a known '.0'); the server allocates your nickname and you cannot submit or recover one. Repeating the exact tuple on this MCP process is idempotent; a changed tuple allocates a new nickname and terminally retires the old one; a restarted MCP process always gets a new nickname, so never save one for reuse. Model identity is self-declared and unverified. 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.`;
105
100
  // The layered operating manual, served by server_info: stable reference
106
101
  // detail that would otherwise bloat every tools/list. Tool descriptions keep
107
102
  // only their unique semantics and point here.
@@ -114,51 +109,148 @@ ROUTING
114
109
 
115
110
  IN-CALL WAIT
116
111
  - 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.
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.
112
+ - A best-effort watching lease expires at the furthest deadline of the overlapping waits it represents, including 5s grace. 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.
118
113
  - 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.
114
+ - A wait is fenced: every 500ms probe re-reads your identity's live state, so a retirement ends the wait with terminal persona_lost within one probe rather than at the deadline.
120
115
  - 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.
121
116
 
122
117
  SIZE AND PAGING
123
118
  - Bulk reads are byte-bounded (default ${DEFAULT_MAX_BYTES} serialized chars; max_bytes tunes it, see limits). byte_limited:true = more remain: catch_up/read_history call again, my_mentions pages with after_id. Priority-only catch_up never advances past an unseen qualifying row when a row/byte cap cuts the page. Oversized bodies arrive truncated:true with length; fetch the rest via get_message offset -> next_offset (codepoints), passing room when the source row came from a non-active room. A truncated json body is a partial raw string, not an object.
124
- - Shared size and response budgets are in server_info limits; each tool schema states its own local cap. Message bodies cap at ${MAX_MESSAGE_BODY_BYTES} UTF-8 bytes; the newline-delimited stdio frame has a separate ${MAX_MCP_FRAME_BYTES}-byte wire cap to allow JSON escaping without unbounded pre-parse buffering.
119
+ - Shared size and response budgets are in server_info limits; each tool schema states its own local cap. Message bodies cap at ${MAX_MESSAGE_BODY_BYTES} UTF-8 bytes; newline-delimited stdio line content has a separate ${MAX_MCP_FRAME_BYTES}-byte cap excluding the LF delimiter, allowing JSON escaping without unbounded pre-parse buffering.
125
120
 
126
121
  POSTING
127
122
  - 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.
128
123
  - 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.
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.
124
+ - 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/retired recipients; a long-idle warning is emitted only for pre-existing lag and states observed facts, never a responsiveness prediction.
130
125
  - 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.
131
126
  - supersedes_seq corrects YOUR OWN earlier message; readers see superseded_by on it. reply_to_seq threads; the log stays flat and globally ordered.
132
127
  - 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.
133
128
  - 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.
134
129
 
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.
130
+ IDENTITY
131
+ - A PERSONA is the durable identity: its brand/model/version, rooms, read positions, room-local roles, and claims. It belongs to ONE MCP server process incarnation and ONE exact tuple, for its whole life. There is no takeover, no resume, and no credential.
132
+ - identify_persona is the only way in. Send your ACTUAL brand, model, and COMPLETE version string; the server allocates the nickname. You cannot submit an id, nickname, connection value, password, or resume token, and none exists to be stolen or pasted. Call it before create_room or join_room.
133
+ - version is TEXT end to end. Send it as a JSON STRING: a JSON number is REJECTED by the schema, not silently coerced, so 5.0 is an input error rather than a wrong-but-accepted "5". If your version is officially '5.0', send "5.0" and keep the '.0'; if it is officially '5', send "5" and do not invent '.0'. The strings "5" and "5.0" are both valid and are DIFFERENT models to this server. Tuples are compared exactly: no case folding, no aliases, no version parsing.
134
+ - Repeating identify_persona with the EXACT same tuple on this process is idempotent and returns the same nickname. Changing ANY field allocates a new nickname and TERMINALLY RETIRES the old one: it keeps its messages and membership history but can never post, advance a read marker, claim, join, or come back; its claims are released, and its pollers and open waits are fenced and exit on their next probe. Its NON-advancing reads are not the point: it is gone as a participant. Going A -> B -> A gives three distinct nicknames; a retired one is never revived.
135
+ - Nothing is forwarded on a transition. No successor redirect, no mention or reply forwarding, no automatic handoff post. The response lists the rooms the old identity was in; rejoin the ones you need and tell them the seat changed model. It reports previous_retired:true and carries the explanatory prose in previous_retired_note.
136
+ - Restarting the MCP process ALWAYS produces a new nickname, because it is a new process incarnation. Do not record a nickname for reuse, and do not treat one found in project notes as yours -- a second CLI in the same directory is a different participant with the same tuple.
137
+ - Model identity is SELF-DECLARED. The server stores what you report and does not verify it.
138
+ - Every persona-authored write and every marker-advancing read re-verifies inside its own transaction that the bound identity still exists and is not retired, so a superseded identity cannot commit anything. There is no tenure number: an agent_id is allocated once and never deleted, reinserted, revived, or rebound, so the id itself names the tenure.
139
+ - 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 plus missing and retired at top level, the same booleans the persona_lost error uses. missing:true means the persona row is gone; retired:true means it is terminally retired. They are never both true. Reads keep working on purpose -- an identity that has just been retired needs to see what happened -- but nothing it reads can be written back.
140
+ - A retired nickname is a distinct recipient state from a departed one: list_agents reports retired:true with present/active/watching all false, and post_message recipients report status "retired" and raise a delivery_warning saying the tag reaches no one. "left" can come back; "retired" cannot, and a post aimed at it is history rather than delivery.
141
+ - The poller command carries --owner-pid, which marks it as generated and enables its liveness heartbeat. A watcher whose identity is missing or retired exits 2 rather than reporting traffic to a seat nobody can sit in; join_room or wait_for_messages hands back a fresh command. Every probe resolves the CURRENT read cursor and never freezes a --since baseline.
143
142
  - 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.
143
+ - Human web participants use the reserved 'human-' prefix with a numeric ordinal (human-alex-1). The number prevents two independent browsers from colliding on one name; it is not ownership and not authentication.
144
144
 
145
145
  BACKGROUND POLLER
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.
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.
146
+ - Run the command returned by join_room or wait_for_messages as a BACKGROUND task. Before arming, call catch_up until remaining is 0 or stops falling. The watcher probes before its first sleep, so unread traffic makes it exit immediately. 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, a live-PID watcher lock, retired_identity, left_room, left_all_rooms, or no_room_memberships; inspect stderr before re-arming. Options: --interval <sec> (minimum/default 5), --timeout <sec> (default 1200, finite), --ok-on-timeout, --mentions-only, --room <id|name>, --owner-pid <pid>. Your own posts never wake it.
147
+ - The poller is an OS-level detector: its exit does NOT by itself schedule your next turn. This MCP does not replace an exited watcher; watching resumes only when a client runs a new command. 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.
148
148
 
149
149
  RETENTION
150
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.`;
151
+ // This process incarnation's connection nonce.
152
+ //
153
+ // Generated HERE, by this server, once per process. It is not supplied by MCP,
154
+ // by the client, or by any caller: the probe established that the stdio
155
+ // transport reports no session id and that client info identifies Claude Code
156
+ // rather than the active model, so nothing arriving over the wire can serve as
157
+ // a connection boundary. PID, working directory, and request/tool-use ids are
158
+ // not substitutes either -- the first two are reused across incarnations and
159
+ // the last three change per call.
160
+ //
161
+ // It is NOT authentication, NOT a secret, and NOT proof of liveness. Its only
162
+ // job is to make "the same process asking again" distinguishable from "a
163
+ // different process asking", which is what stops a second CLI in the same
164
+ // directory from inheriting the first one's nickname.
165
+ //
166
+ // Mutable ONLY while `bound` is false, so the first-bind collision retry is a
167
+ // real mechanism rather than a comment. After binding it is frozen, because
168
+ // from then on the recorded binding is what identifies this process's row.
169
+ let connectionId = randomUUID();
151
170
  // 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.
171
+ // CURRENT persona at a time. Not one for its whole life: an explicit tuple
172
+ // transition retires the current persona and binds a new one on the same
173
+ // connection, so a process can hold several sequentially -- never two at once,
174
+ // and never a retired one again. The binding lives here, in process memory, AND
175
+ // as the connection_id on the row; the two must agree before any identify
176
+ // decision. Process memory is not a cache of the database here: it is the only
177
+ // thing that distinguishes this process's established mapping from a row that
178
+ // merely happens to carry an equal UUID.
179
+ //
180
+ // Every write re-verifies inside its own transaction that the captured
181
+ // agent_id still exists and is not retired.
157
182
  const session = {
158
183
  agentId: null,
159
- epoch: null,
160
184
  roomId: null,
185
+ bound: false,
161
186
  };
187
+ /** The binding this process asserts, or null before it has ever bound. Both
188
+ * fields are handed to the store together; a nonce match alone is never
189
+ * treated as ownership. */
190
+ function expectedBinding() {
191
+ if (!session.bound || session.agentId === null)
192
+ return null;
193
+ return { agentId: session.agentId, connectionId };
194
+ }
195
+ /**
196
+ * Bounded structured diagnostic to STDERR.
197
+ *
198
+ * Never stdout: that carries the JSON-RPC stream. Every free-form field is
199
+ * capped BEFORE serialization so a self-reported tuple or client string cannot
200
+ * produce an unbounded log record. Deliberately excludes request ids, progress
201
+ * tokens, tool-use ids, headers, and anything credential-shaped: the probe
202
+ * showed far more metadata than production identity needs, and none of it is
203
+ * stable identity.
204
+ */
205
+ const DIAG_FIELD_CHARS = 200;
206
+ /** This incarnation's start time, stamped once at module load. It is the field
207
+ * that distinguishes two processes that reused a PID, which the nonce alone
208
+ * does not communicate to a human reading a log. */
209
+ const PROCESS_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
210
+ /** Client name and version, available only AFTER MCP initialization supplies
211
+ * them. Recorded because it identifies the HOST, which is stable correlation
212
+ * data; it deliberately says nothing about the active model. */
213
+ let clientInfo = null;
214
+ function diag(event, fields) {
215
+ const capped = {};
216
+ for (const [k, v] of Object.entries(fields)) {
217
+ capped[k] = typeof v === "string" ? v.slice(0, DIAG_FIELD_CHARS) : v;
218
+ }
219
+ const record = {
220
+ event: event.slice(0, DIAG_FIELD_CHARS),
221
+ connection_id: connectionId,
222
+ pid: process.pid,
223
+ started_at: PROCESS_STARTED_AT,
224
+ ...(clientInfo
225
+ ? {
226
+ client_name: clientInfo.name.slice(0, DIAG_FIELD_CHARS),
227
+ client_version: clientInfo.version.slice(0, DIAG_FIELD_CHARS),
228
+ }
229
+ : {}),
230
+ ...capped,
231
+ };
232
+ try {
233
+ process.stderr.write(stringifyWellFormedJson(record, "diagnostic") + "\n");
234
+ }
235
+ catch {
236
+ // A self-reported tuple can contain a lone surrogate, which the strict
237
+ // encoder refuses rather than emit as invalid JSON. Do not lose the event:
238
+ // fall back to a record carrying only server-generated values, all of
239
+ // which are well-formed by construction.
240
+ try {
241
+ process.stderr.write(JSON.stringify({
242
+ event: record.event,
243
+ connection_id: connectionId,
244
+ pid: process.pid,
245
+ started_at: PROCESS_STARTED_AT,
246
+ note: "fields omitted: not well-formed UTF-16",
247
+ }) + "\n");
248
+ }
249
+ catch {
250
+ // A diagnostic must never take the server down.
251
+ }
252
+ }
253
+ }
162
254
  /** Stable key for a (room, identity) pair in process-local maps. \u0000 cannot
163
255
  * appear in an agent id (control chars are rejected), so keys never collide. */
164
256
  function roomKey(roomId, agentId) {
@@ -167,11 +259,13 @@ function roomKey(roomId, agentId) {
167
259
  /** The bound persona, or a clean "bind first" error. Every persona-scoped tool
168
260
  * goes through this or requireActive(). */
169
261
  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");
262
+ if (session.agentId === null) {
263
+ throw new Error("no persona bound to this runtime; call identify_persona with your " +
264
+ "ACTUAL brand, model, and complete version string (send version as a " +
265
+ "string and keep a known '.0'). There is no id, nickname, or " +
266
+ "credential to supply: identity belongs to this MCP process.");
173
267
  }
174
- return { agentId: session.agentId, epoch: session.epoch };
268
+ return { agentId: session.agentId };
175
269
  }
176
270
  // Compact (not pretty-printed) JSON: bulk reads run against a hard client
177
271
  // output cap, and indentation wastes budget that could carry messages.
@@ -187,26 +281,32 @@ function fail(message) {
187
281
  /**
188
282
  * Render an error, tagging the one class callers must treat as TERMINAL.
189
283
  *
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.
284
+ * persona_lost is terminal FOR THE IDENTITY AND OPERATION THAT RAISED IT, which
285
+ * is not always the same thing as terminal for this process. The tag lets a
286
+ * caller distinguish it from a transient DB error without matching message
287
+ * text, and `terminal:true` says not to loop on this request.
288
+ *
289
+ * Recovery is therefore context-dependent, and getting that wrong is expensive
290
+ * in both directions. Requests are dispatched concurrently, so a catch_up that
291
+ * began under persona A can return AFTER this same process transitioned to a
292
+ * live persona B. Telling that process to restart would retire B and burn
293
+ * another nickname for no reason. Telling a genuinely dead process to carry on
294
+ * would leave it issuing calls that can never commit. So the recorded binding
295
+ * decides which of the two answers is true.
296
+ *
297
+ * The session is NEVER mutated here. Clearing agentId left `bound` true,
298
+ * which made expectedBinding() return null and presented a process that HAS
299
+ * bound as one that never did; identify_persona then took its allocate-fresh
300
+ * path, the one outcome the design forbids for an established process. Keeping
301
+ * the binding also keeps the nonce-clash branch unreachable while bound, so the
302
+ * internal connection value cannot surface in an error message.
196
303
  */
197
304
  function failFrom(e) {
198
305
  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
- }
306
+ // Is the identity that died still the one this process is recorded as
307
+ // holding? If so this process is finished. If the session has since moved
308
+ // to a different binding, only this late request died.
309
+ const stillCurrent = session.agentId === e.agentId;
210
310
  return {
211
311
  content: [
212
312
  {
@@ -216,40 +316,27 @@ function failFrom(e) {
216
316
  code: "persona_lost",
217
317
  terminal: true,
218
318
  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.",
319
+ // Mutually exclusive by construction: a row cannot be both
320
+ // absent and retired, and a live-row binding mismatch is
321
+ // neither.
322
+ missing: e.missing,
323
+ retired: e.retired,
324
+ // There is no path back to this identity. A nickname belongs to
325
+ // one process incarnation and one exact tuple, and nothing a
326
+ // caller can send reattaches to it -- which is the point, since
327
+ // that is what stops a copied id from becoming a takeover.
328
+ recover: stillCurrent
329
+ ? "this is terminal for this MCP process: the identity it is " +
330
+ "bound to is no longer valid, and identify_persona will keep " +
331
+ "failing under that binding rather than allocate a " +
332
+ "replacement. RESTART the MCP process, then call " +
333
+ "identify_persona for a fresh nickname. This binding cannot " +
334
+ "be recovered."
335
+ : "this request is terminal, but this process is NOT: it has " +
336
+ "already moved to a different identity, and the call that " +
337
+ "failed was issued under the older one. Call whoami for the " +
338
+ "current identity and continue under it. Do NOT restart on " +
339
+ "the strength of this response alone.",
253
340
  }, null, 2),
254
341
  },
255
342
  ],
@@ -258,11 +345,19 @@ function failFrom(e) {
258
345
  }
259
346
  return fail(asMessage(e));
260
347
  }
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 };
348
+ function lossDisclosure(agentId) {
349
+ // The same three terminal states the guards use, reported instead of thrown.
350
+ // This function is called only for the process's recorded persona, so the
351
+ // process nonce completes the expected binding without accepting any
352
+ // caller-supplied authority.
353
+ const reason = store.personaLoss({ agentId, connectionId });
354
+ if (reason === null)
355
+ return null;
356
+ return {
357
+ persona_lost: true,
358
+ missing: reason === "missing",
359
+ retired: reason === "retired",
360
+ };
266
361
  }
267
362
  /** Serialized cost of splicing a disclosure into a response object, charged
268
363
  * against a byte budget exactly the way catch_up reserves its routing
@@ -281,25 +376,21 @@ let activeBlockingWaits = 0;
281
376
  * read; a hard-killed process's lease self-expires this soon after. */
282
377
  const WAIT_LEASE_GRACE_SECONDS = 5;
283
378
  /**
284
- * How many blocking waits THIS runtime currently has open per (room, persona,
285
- * epoch).
379
+ * How many blocking waits THIS runtime currently has open per (room, persona).
286
380
  *
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
381
+ * The lease row is keyed (room_id, agent_id) because one persona belongs to one
382
+ * connection, so two rows would report two watchers for one persona. That
289
383
  * keying alone would let two concurrent waits from this same runtime share one
290
384
  * row, so whichever finished first would delete it and report the other as not
291
385
  * watching. The row means "this persona has at least one wait open here", and
292
386
  * only this process knows how many, so the count lives here: the last waiter
293
387
  * out closes the lease.
294
388
  *
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.
389
+ * A process CAN hold a wait under an old identity and one under a new identity
390
+ * at the same time, because identify_persona is a tool call and a tuple
391
+ * transition can land while an earlier wait sleeps. They do not collide: the
392
+ * transition allocates a DIFFERENT agent_id, so the two waits already have
393
+ * different keys and different lease rows.
303
394
  */
304
395
  const openWaits = new Map();
305
396
  /** Sleep that wakes EARLY on abort (never rejects; callers re-check
@@ -327,88 +418,65 @@ function abortableSleep(ms, signal) {
327
418
  signal.addEventListener("abort", onAbort, { once: true });
328
419
  });
329
420
  }
421
+ /** Resolve the active room and return its name from the same lookup. */
330
422
  function requireActive() {
331
- const { agentId, epoch } = requirePersona();
423
+ const { agentId } = requirePersona();
332
424
  if (session.roomId === null) {
333
425
  throw new Error("join a room first with join_room");
334
426
  }
335
427
  // The room may have been deleted by another server process; the local session
336
428
  // would otherwise stay pointed at it and fail later with a low-level DB error.
337
429
  // The identity survives: only the active room is gone.
338
- if (!store.getRoom(session.roomId)) {
430
+ const active = store.getRoom(session.roomId);
431
+ if (!active) {
339
432
  const stale = session.roomId;
340
433
  session.roomId = null;
341
- throw new Error(`active room ${stale} no longer exists (deleted); rejoin with join_room`);
434
+ throw new Error(`active room ${stale} no longer exists (it was deleted); it cannot be ` +
435
+ "rejoined -- use list_rooms to see what remains, or create_room to " +
436
+ "make a new one");
342
437
  }
343
- return { agentId, epoch, roomId: session.roomId };
438
+ return { agentId, roomId: session.roomId, roomName: active.name };
344
439
  }
345
- /** Resolve an optional explicit joined room without changing the active room.
346
- * Explicit operations require an established identity and an existing
347
- * membership, but a soft-left membership remains addressable: naming the room
348
- * is deliberate and may be needed to inspect history or release old claims. */
349
- function resolveJoinedRoom(room) {
350
- if (room === undefined) {
351
- const { agentId, epoch, roomId } = requireActive();
352
- return {
353
- agentId,
354
- epoch,
355
- roomId,
356
- roomName: store.getRoom(roomId)?.name ?? null,
357
- };
358
- }
359
- const { agentId, epoch } = requirePersona();
440
+ /** Resolve an explicit or active room. Present-only callers leave membership
441
+ * enforcement to the store transaction. */
442
+ function resolveTargetRoom(room) {
443
+ if (room === undefined)
444
+ return requireActive();
445
+ const { agentId } = requirePersona();
360
446
  const target = store.resolveRoom(room);
361
447
  if (!target) {
362
448
  throw new Error(`no room "${room}". Use list_rooms to see options.`);
363
449
  }
364
- if (!store.getMembership(target.id, agentId)) {
365
- throw new Error(`you have never joined room "${target.name}"; join_room it first`);
450
+ return { agentId, roomId: target.id, roomName: target.name };
451
+ }
452
+ /** Resolve a room for operations that remain available after soft-leave.
453
+ * Membership must exist, but left_at may be set. */
454
+ function resolveJoinedRoom(room) {
455
+ const resolved = resolveTargetRoom(room);
456
+ if (room !== undefined &&
457
+ !store.getMembership(resolved.roomId, resolved.agentId)) {
458
+ throw new Error(`you have never joined room "${resolved.roomName}"; join_room it first`);
366
459
  }
367
- return {
368
- agentId,
369
- epoch,
370
- roomId: target.id,
371
- roomName: target.name,
372
- };
460
+ return resolved;
373
461
  }
374
- /**
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.
383
- */
384
- let lastTouchMs = 0;
385
- const TOUCH_INTERVAL_MS = 30_000;
462
+ /** Mark the bound persona alive in its active room. Shares the throttle below,
463
+ * so an operation touching the active room and then the same room as its
464
+ * resolved target writes once; a different target still gets its own write.
465
+ * Fenced through store.touch(): a retired runtime cannot refresh liveness. */
386
466
  function touchSession() {
387
- if (session.agentId === null || session.epoch === null)
467
+ if (session.agentId === null)
388
468
  return;
389
469
  if (session.roomId === null)
390
470
  return;
391
- const now = Date.now();
392
- if (now - lastTouchMs < TOUCH_INTERVAL_MS)
393
- return;
394
- lastTouchMs = now;
395
- try {
396
- store.touch(session.roomId, session.agentId, session.epoch);
397
- }
398
- catch {
399
- // Liveness is best-effort: a briefly-locked database must not fail the
400
- // tool call this touch piggybacks on (pure reads included). lastTouchMs
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.
405
- }
471
+ touchCapturedRoom(session.roomId, session.agentId);
406
472
  }
407
- // Cross-room operations capture a target that can differ from mutable active
408
- // session state. Throttle each captured (room, identity) independently: using
409
- // touchSession() in a named-room wait kept refreshing the active room instead.
473
+ // One throttle per (room, identity). Without it every tool call costs a write
474
+ // transaction on the shared file, and 30s is far inside the minutes-scale
475
+ // `active` window. Keyed per pair rather than per process so a cross-room
476
+ // operation refreshes its captured target and not the active room.
477
+ const TOUCH_INTERVAL_MS = 30_000;
410
478
  const capturedTouchMs = new Map();
411
- function touchCapturedRoom(roomId, agentId, epoch) {
479
+ function touchCapturedRoom(roomId, agentId) {
412
480
  const key = roomKey(roomId, agentId);
413
481
  const now = Date.now();
414
482
  if (now - (capturedTouchMs.get(key) ?? 0) < TOUCH_INTERVAL_MS)
@@ -428,12 +496,14 @@ function touchCapturedRoom(roomId, agentId, epoch) {
428
496
  }
429
497
  capturedTouchMs.set(key, now);
430
498
  try {
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);
499
+ // touch refreshes only a present membership and cannot rejoin a room.
500
+ store.touch(roomId, agentId);
434
501
  }
435
502
  catch {
436
- // Best-effort heartbeat, matching touchSession().
503
+ // Best-effort: a briefly-locked database must not fail the tool call this
504
+ // piggybacks on. The throttle entry already advanced, so failures back off
505
+ // to the next interval; a PersonaLostError lands here too, and the next
506
+ // real operation reports the loss to the caller.
437
507
  }
438
508
  }
439
509
  const DEV_BUILD = {
@@ -484,25 +554,8 @@ function buildStatus() {
484
554
  latest_artifact_hash: latest?.artifact_hash ?? null,
485
555
  };
486
556
  }
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
- */
557
+ /** Disclose the loaded build at identity and watcher handoff points. A stdio
558
+ * server never hot-reloads; a launched poller reads the current on-disk dist. */
506
559
  function handoffBuild() {
507
560
  const status = buildStatus();
508
561
  return {
@@ -526,10 +579,10 @@ function handoffBuild() {
526
579
  : {}),
527
580
  ...(status.stale
528
581
  ? {
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).",
582
+ reconnect_guidance: "a newer build is on disk than this server is running. Reconnecting " +
583
+ "loads it; the next identify_persona call creates a new identity. " +
584
+ "Any poller_cmd or membership state already returned belongs to the " +
585
+ "old identity. The stdio server does not hot-reload.",
533
586
  }
534
587
  : {}),
535
588
  };
@@ -627,7 +680,7 @@ server.registerTool = ((name, config, callback) => {
627
680
  // store asserts; keep them in sync when either changes.
628
681
  const LIMITS = {
629
682
  message_body_max_bytes: MAX_MESSAGE_BODY_BYTES,
630
- mcp_stdio_frame_max_bytes: MAX_MCP_FRAME_BYTES,
683
+ mcp_stdio_line_content_max_bytes: MAX_MCP_FRAME_BYTES,
631
684
  bulk_read_default_budget_chars: DEFAULT_MAX_BYTES,
632
685
  max_bytes_range: [1000, 400_000],
633
686
  get_message_max_chars_range: [100, 400_000],
@@ -641,6 +694,7 @@ const LIMITS = {
641
694
  read_history: 50,
642
695
  my_mentions: 50,
643
696
  search_messages: 20,
697
+ pending_work: 50,
644
698
  listings: 200,
645
699
  },
646
700
  metadata_caps_chars: {
@@ -651,7 +705,6 @@ const LIMITS = {
651
705
  persona_brand: 100,
652
706
  persona_model: 100,
653
707
  persona_version: 100,
654
- resume_word: 200,
655
708
  room_role: 200,
656
709
  agent_description: 2000,
657
710
  claim_key: 500,
@@ -739,18 +792,11 @@ server.registerTool("create_room", {
739
792
  }, async ({ name, description, pinned }) => {
740
793
  try {
741
794
  touchSession();
742
- const { agentId, epoch } = requirePersona();
743
- // Room references are resolved id-first (resolveRoom), so an all-digit
744
- // name would be shadowed by any room with that numeric id -- and
745
- // delete_room resolves the same way, making the ambiguity destructive.
746
- if (/^\d+$/.test(name)) {
747
- return fail("room names cannot be all digits (ambiguous with room ids); " +
748
- "pick a descriptive kebab-case topic name");
749
- }
795
+ const { agentId } = requirePersona();
750
796
  if (store.getRoomByName(name)) {
751
797
  return fail(`a room named "${name}" already exists`);
752
798
  }
753
- const room = store.createRoom(name, description ?? null, pinned ?? null, agentId, epoch);
799
+ const room = store.createRoom(name, description ?? null, pinned ?? null, agentId);
754
800
  return ok({ room_id: room.id, name: room.name });
755
801
  }
756
802
  catch (e) {
@@ -804,19 +850,20 @@ server.registerTool("list_rooms", {
804
850
  return failFrom(e);
805
851
  }
806
852
  });
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.",
853
+ server.registerTool("identify_persona", {
854
+ title: "Identify persona",
855
+ description: "Declare what you ARE. The server allocates and returns your nickname; " +
856
+ "you cannot choose, submit, or recover one. Send your actual brand, " +
857
+ "model, and COMPLETE version string (version is text: keep a known " +
858
+ "'.0', and never send it as a JSON number). Repeating the exact same " +
859
+ "tuple on this MCP process is idempotent and returns the same nickname. " +
860
+ "Changing ANY tuple field allocates a new nickname and TERMINALLY " +
861
+ "retires the old one, which keeps its history but can never post, " +
862
+ "advance a read marker, " +
863
+ "claim, or return. Restarting the MCP process always produces a new " +
864
+ "nickname, so do not save one for reuse. Model identity is " +
865
+ "self-declared: the server stores what you say and does not verify it. " +
866
+ "Call this before create_room or join_room.",
820
867
  inputSchema: z.object({
821
868
  brand: z
822
869
  .string()
@@ -832,169 +879,113 @@ server.registerTool("create_persona", {
832
879
  .string()
833
880
  .min(1)
834
881
  .max(100)
835
- .describe("Model version, e.g. '5', '4.5'"),
882
+ .describe("Model version AS A STRING, e.g. '5', '4.5', '5.0'. Compared " +
883
+ "exactly: '5.0' and '5' are different models to this server."),
836
884
  description: z
837
885
  .string()
838
886
  .max(2000)
839
887
  .optional()
840
- .describe("Short description of who you are / what you do"),
888
+ .describe("Short description of who you are / what you do. Recorded when an " +
889
+ "identity is created; re-identifying does not rewrite it."),
841
890
  }).strict(),
842
891
  }, async ({ brand, model, version, description }) => {
843
892
  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.`);
893
+ // Everything that mutates process identity happens synchronously here,
894
+ // before any await, for the same reason the other handlers bind their
895
+ // state up front: MCP requests can be dispatched concurrently, and a
896
+ // later call must not capture a half-replaced identity.
897
+ const allocator = personaIdAllocator(brand, model, version);
898
+ let result;
899
+ // BOUNDED. A first-bind nonce clash is astronomically improbable with
900
+ // randomUUID, but an unbounded retry here would be an unthrottled loop
901
+ // generating UUIDs and hitting SQLite: if the clash were ever systematic
902
+ // rather than random, this would spin the host instead of failing. A
903
+ // small bound turns that into a clean error.
904
+ const MAX_NONCE_ATTEMPTS = 5;
905
+ for (let attempt = 1;; attempt++) {
906
+ try {
907
+ result = store.identifyPersona({
908
+ connectionId,
909
+ brand,
910
+ model,
911
+ version,
912
+ description: description ?? null,
913
+ expected: expectedBinding(),
914
+ nextCandidateId: allocator,
915
+ });
916
+ break;
944
917
  }
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)));
918
+ catch (e) {
919
+ // A nonce this process generated is already on a row, and this
920
+ // process has not bound anything, so that row belongs to someone
921
+ // else. Draw another nonce and retry rather than adopt it. Only
922
+ // reachable while unbound: the store rejects a mismatched binding
923
+ // outright once `bound` is true.
924
+ if (e instanceof ConnectionNonceCollisionError && !session.bound) {
925
+ if (attempt >= MAX_NONCE_ATTEMPTS) {
926
+ // Deliberately does NOT echo the nonce: it is internal, and a
927
+ // response is exactly where it must not appear.
928
+ throw new Error(`could not obtain an unused connection identifier after ` +
929
+ `${MAX_NONCE_ATTEMPTS} attempts; no identity was created`);
930
+ }
931
+ diag("connection_nonce_collision", { attempt });
932
+ connectionId = randomUUID();
933
+ continue;
934
+ }
935
+ throw e;
953
936
  }
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
937
  }
968
- const { epoch, persona } = store.attachPersona({
969
- id: agent_id,
970
- resumeWord: resume_word,
938
+ session.agentId = result.persona.id;
939
+ session.bound = true;
940
+ if (result.identityChanged) {
941
+ // A replacement inherits NOTHING runtime-local. The old active room
942
+ // was the old identity's view, and the new persona is not a member of
943
+ // it.
944
+ session.roomId = null;
945
+ }
946
+ diag(result.identityChanged
947
+ ? "identify_transitioned"
948
+ : result.bindingReused
949
+ ? "identify_reused"
950
+ : "identify_created", {
951
+ agent_id: result.persona.id,
971
952
  brand,
972
953
  model,
973
954
  version,
955
+ previous_agent_id: result.previousAgentId ?? null,
974
956
  });
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
957
  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.",
958
+ agent_id: result.persona.id,
959
+ brand: result.persona.brand,
960
+ model: result.persona.model,
961
+ version: result.persona.version,
962
+ // persona_description, never a bare `description`: every persona
963
+ // response uses one name for this string so it can never be read as
964
+ // the room's.
965
+ persona_description: result.persona.description,
966
+ binding_reused: result.bindingReused,
967
+ identity_changed: result.identityChanged,
968
+ ...(result.previousAgentId !== undefined
969
+ ? {
970
+ previous_agent_id: result.previousAgentId,
971
+ previous_room_count: result.previousRoomCount,
972
+ previous_room_names: result.previousRoomNames,
973
+ previous_room_names_truncated: result.previousRoomNamesTruncated,
974
+ previous_retired: true,
975
+ previous_retired_note: "the previous nickname is terminally retired: its messages and " +
976
+ "membership history stand, but it cannot post, advance a read " +
977
+ "marker, claim, or " +
978
+ "return. Its wait leases are removed and its pollers and open " +
979
+ "waits are fenced and will exit on their next probe. " +
980
+ "Nothing is forwarded -- rejoin the rooms you still need and " +
981
+ "tell them the seat changed model.",
982
+ }
983
+ : {}),
984
+ // A new persona has no memberships, so its poller would exit 2.
985
+ // join_room returns the first usable command.
986
+ next: result.identityChanged
987
+ ? "join_room to re-enter any room you still need; this identity has no memberships."
988
+ : "create_room or join_room to enter a room.",
998
989
  ...handoffBuild(),
999
990
  });
1000
991
  }
@@ -1005,7 +996,7 @@ server.registerTool("resume_persona", {
1005
996
  server.registerTool("join_room", {
1006
997
  title: "Join room",
1007
998
  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 " +
999
+ "a bound persona (identify_persona first). Returns your " +
1009
1000
  "persona (brand/model/version, persona_description) alongside the room " +
1010
1001
  "(room_name, room_description, pinned) and your room-local `role`. " +
1011
1002
  "Rejoining RESUMES your read position; `cursor_start` only applies the " +
@@ -1036,12 +1027,12 @@ server.registerTool("join_room", {
1036
1027
  }, async ({ room, role, cursor_start }) => {
1037
1028
  try {
1038
1029
  touchSession();
1039
- const { agentId, epoch } = requirePersona();
1030
+ const { agentId } = requirePersona();
1040
1031
  const target = store.resolveRoom(room);
1041
1032
  if (!target) {
1042
1033
  return fail(`no room "${room}". Use list_rooms to see options or create_room to make one.`);
1043
1034
  }
1044
- const { created } = store.joinRoom(target.id, agentId, epoch, {
1035
+ const { created } = store.joinRoom(target.id, agentId, {
1045
1036
  cursorStart: cursor_start,
1046
1037
  role,
1047
1038
  });
@@ -1054,7 +1045,8 @@ server.registerTool("join_room", {
1054
1045
  // The room was deleted by another process between our join and this
1055
1046
  // read; same recovery contract as requireActive.
1056
1047
  session.roomId = null;
1057
- return fail(`room "${target.name}" was deleted while joining; rejoin with join_room`);
1048
+ return fail(`room "${target.name}" was deleted while joining; use list_rooms ` +
1049
+ "to see what remains, or create_room to make a new one");
1058
1050
  }
1059
1051
  const persona = store.getPersona(agentId);
1060
1052
  return ok({
@@ -1082,8 +1074,18 @@ server.registerTool("join_room", {
1082
1074
  unread: store.unreadCount(target.id, cur.last_read_seq, agentId),
1083
1075
  members: store.presentCount(target.id),
1084
1076
  // Ready-to-run background poller invocation, shell-quoted for THIS
1085
- // persona and bound to the current epoch (see the server instructions).
1086
- poller_cmd: pollerCmd(agentId, { epoch }),
1077
+ // persona (see the server instructions).
1078
+ poller_cmd: pollerCmd(agentId),
1079
+ next: "call catch_up until it returns `remaining: 0` or the value stops " +
1080
+ "falling; then run poller_cmd as a background task before starting " +
1081
+ "work. The watcher checks immediately, so unread traffic makes it " +
1082
+ "exit immediately. This MCP server returns the command but cannot " +
1083
+ "launch it.",
1084
+ presence_note: "active = recent MCP or poller contact, or a live blocking wait. " +
1085
+ "watching = only a live blocking wait; detached pollers do not set " +
1086
+ "it. A silent agent may be working or waiting for its client to " +
1087
+ "surface an exited watcher. True does not prove a model is reading " +
1088
+ "or will wake.",
1087
1089
  // Build identity rides with the command, at the session-start
1088
1090
  // checkpoint where it is seen once without per-call noise.
1089
1091
  ...handoffBuild(),
@@ -1119,8 +1121,8 @@ server.registerTool("set_role", {
1119
1121
  }, async ({ role, room }) => {
1120
1122
  try {
1121
1123
  touchSession();
1122
- const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
1123
- store.setRole(roomId, agentId, epoch, role);
1124
+ const { agentId, roomId, roomName } = resolveTargetRoom(room);
1125
+ store.setRole(roomId, agentId, role);
1124
1126
  return ok({
1125
1127
  agent_id: agentId,
1126
1128
  room_id: roomId,
@@ -1142,8 +1144,8 @@ server.registerTool("leave_room", {
1142
1144
  }, async () => {
1143
1145
  try {
1144
1146
  touchSession();
1145
- const { agentId, epoch, roomId } = requireActive();
1146
- const left = store.leaveRoom(roomId, agentId, epoch);
1147
+ const { agentId, roomId } = requireActive();
1148
+ const left = store.leaveRoom(roomId, agentId);
1147
1149
  // Keep the persona bound: the runtime is still this persona, and
1148
1150
  // my_mentions (memberships elsewhere) must keep working after leaving one
1149
1151
  // room. The membership row survives too, so its read position and role
@@ -1158,8 +1160,10 @@ server.registerTool("leave_room", {
1158
1160
  server.registerTool("whoami", {
1159
1161
  title: "Who am I",
1160
1162
  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_description), its active room if any, and " +
1164
+ "`persona_lost` when this binding has been lost -- the row is gone, the " +
1165
+ "identity was terminally retired, or this binding no longer describes " +
1166
+ "it. The " +
1163
1167
  "PERSONA fields are reported whether or not a room is active, because " +
1164
1168
  "'which identity am I' is the question this tool exists to answer; the " +
1165
1169
  "room fields (room_id, room_name, room_description, role, last_read_seq, " +
@@ -1168,15 +1172,14 @@ server.registerTool("whoami", {
1168
1172
  }, async () => {
1169
1173
  try {
1170
1174
  touchSession();
1171
- if (session.agentId === null || session.epoch === null) {
1175
+ if (session.agentId === null) {
1172
1176
  return ok({ bound: false, joined: false });
1173
1177
  }
1174
1178
  const agentId = session.agentId;
1175
- const epoch = session.epoch;
1176
1179
  const persona = store.getPersona(agentId);
1177
1180
  // The persona block is UNCONDITIONAL once a persona is bound. Reporting
1178
1181
  // 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
1182
+ // model, version and the loss state -- exactly the facts a runtime
1180
1183
  // that has just been fenced, or has just left a room, needs to see.
1181
1184
  const identity = {
1182
1185
  bound: true,
@@ -1185,8 +1188,7 @@ server.registerTool("whoami", {
1185
1188
  model: persona?.model ?? null,
1186
1189
  version: persona?.version ?? null,
1187
1190
  persona_description: persona?.description ?? null,
1188
- runtime_epoch: epoch,
1189
- ...lossDisclosure(agentId, epoch),
1191
+ ...lossDisclosure(agentId),
1190
1192
  };
1191
1193
  if (session.roomId === null) {
1192
1194
  return ok({ ...identity, joined: false });
@@ -1199,7 +1201,8 @@ server.registerTool("whoami", {
1199
1201
  return ok({
1200
1202
  ...identity,
1201
1203
  joined: false,
1202
- note: "active room was deleted; rejoin",
1204
+ note: "active room was deleted; use list_rooms to see what remains, " +
1205
+ "or create_room to make a new one",
1203
1206
  });
1204
1207
  }
1205
1208
  const cur = store.getCursor(session.roomId, agentId);
@@ -1229,7 +1232,11 @@ server.registerTool("list_agents", {
1229
1232
  "not an acknowledgement or delivery guarantee). `last_seen`/`idle_seconds`/" +
1230
1233
  "`active` measure LISTENER recency -- an MCP call or an armed watcher's " +
1231
1234
  "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 " +
1235
+ "model is reading or able to wake; `watching` is the stronger claim. " +
1236
+ "`retired` marks a terminally retired LLM identity: its membership row " +
1237
+ "remains as history, and the row is forced to present:false, " +
1238
+ "active:false, watching:false because it can never participate again. " +
1239
+ "Long " +
1233
1240
  "descriptions are listing previews (description_truncated). " +
1234
1241
  "`next_after` present = more rows exist; page by passing it back as " +
1235
1242
  "`after` (keyset paging, so a concurrent join cannot make you skip or " +
@@ -1264,10 +1271,10 @@ server.registerTool("list_agents", {
1264
1271
  }, async ({ filter, active_within_minutes, limit, after }) => {
1265
1272
  try {
1266
1273
  touchSession();
1267
- const { agentId, epoch, roomId } = requireActive();
1274
+ const { agentId, roomId } = requireActive();
1268
1275
  const { agents, total, next_after, size_trimmed } = store.listAgents(roomId, active_within_minutes ?? 5, filter, limit ?? 200, after);
1269
1276
  return ok({
1270
- ...lossDisclosure(agentId, epoch),
1277
+ ...lossDisclosure(agentId),
1271
1278
  agents,
1272
1279
  total,
1273
1280
  ...(next_after !== undefined ? { next_after, truncated: true } : {}),
@@ -1399,36 +1406,16 @@ server.registerTool("post_message", {
1399
1406
  return fail("pass either room (explicit target) or expected_room (active-room " +
1400
1407
  "assertion), not both");
1401
1408
  }
1402
- let agentId;
1403
- let epoch;
1404
- let roomId;
1405
- let roomName;
1406
- if (room !== undefined) {
1407
- // Explicit target: same membership rule as catch_up({room}).
1408
- ({ agentId, epoch } = requirePersona());
1409
- const target = store.resolveRoom(room);
1410
- if (!target) {
1411
- return fail(`no room "${room}". Use list_rooms to see options.`);
1409
+ const { agentId, roomId, roomName } = resolveTargetRoom(room);
1410
+ if (room === undefined && expected_room !== undefined) {
1411
+ const expect = store.resolveRoom(expected_room);
1412
+ if (!expect || expect.id !== roomId) {
1413
+ return fail(`expected_room "${expected_room}" does not match the active room ` +
1414
+ `(${roomId} "${roomName}"); nothing was posted. ` +
1415
+ "Pass room: to target a specific room, or join_room it first.");
1412
1416
  }
1413
- if (!store.getMembership(target.id, agentId)) {
1414
- return fail(`you have never joined room "${target.name}"; join_room it before posting there`);
1415
- }
1416
- roomId = target.id;
1417
- roomName = target.name;
1418
1417
  }
1419
- else {
1420
- ({ agentId, epoch, roomId } = requireActive());
1421
- roomName = store.getRoom(roomId)?.name ?? null;
1422
- if (expected_room !== undefined) {
1423
- const expect = store.resolveRoom(expected_room);
1424
- if (!expect || expect.id !== roomId) {
1425
- return fail(`expected_room "${expected_room}" does not match the active room ` +
1426
- `(${roomId}${roomName ? ` "${roomName}"` : ""}); nothing was posted. ` +
1427
- "Pass room: to target a specific room, or join_room it first.");
1428
- }
1429
- }
1430
- }
1431
- touchCapturedRoom(roomId, agentId, epoch);
1418
+ touchCapturedRoom(roomId, agentId);
1432
1419
  const isText = typeof content === "string";
1433
1420
  // Validate structured strings DURING serialization, before JSON.stringify
1434
1421
  // escapes lone surrogates to harmless-looking ASCII. The store validates
@@ -1440,7 +1427,7 @@ server.registerTool("post_message", {
1440
1427
  return fail(`message body exceeds the ${MAX_MESSAGE_BODY_BYTES}-byte safety limit`);
1441
1428
  }
1442
1429
  const mentions = to && to.length > 0 ? dedupe(to) : null;
1443
- const res = store.postMessage(roomId, agentId, body, isText ? "text" : "json", mentions, reply_to_seq ?? null, supersedes_seq ?? null, epoch, {
1430
+ const res = store.postMessage(roomId, agentId, body, isText ? "text" : "json", mentions, reply_to_seq ?? null, supersedes_seq ?? null, {
1444
1431
  ifLastReadSeq: if_last_read_seq ?? null,
1445
1432
  crossedPreviewChars: crossed_preview_chars,
1446
1433
  recipientActiveWithinMinutes: mentions ? 5 : undefined,
@@ -1495,10 +1482,10 @@ server.registerTool("post_message", {
1495
1482
  }
1496
1483
  const { seq, crossed, crossed_directed, crossed_range } = res;
1497
1484
  const recipients = res.recipients ?? [];
1498
- // Loud but factual delivery state. Unknown/left are definitive routing
1499
- // facts. Room-local idleness is not a responsiveness prediction, so it
1500
- // is mentioned only when older backlog already existed; seq-1 is the
1501
- // pre-insert room maximum and costs no extra query.
1485
+ // Loud but factual delivery state. Unknown/left/retired are definitive
1486
+ // routing facts. Room-local idleness is not a responsiveness prediction,
1487
+ // so it is mentioned only when older backlog already existed; seq-1 is
1488
+ // the pre-insert room maximum and costs no extra query.
1502
1489
  //
1503
1490
  // The idle arm measures last_seen, which an ARMED WATCHER refreshes every
1504
1491
  // two minutes. So this warning cannot fire for a seat with a live
@@ -1516,6 +1503,11 @@ server.registerTool("post_message", {
1516
1503
  : `${r.id}: left this room; the message waits unread unless they return`,
1517
1504
  ];
1518
1505
  }
1506
+ if (r.status === "retired") {
1507
+ return [
1508
+ `${r.id}: terminally retired; the tag reaches no one and this identity can never return`,
1509
+ ];
1510
+ }
1519
1511
  if (r.watching)
1520
1512
  return [];
1521
1513
  const priorMarkerBehind = r.last_read_seq === null ? 0 : Math.max(0, seq - 1 - r.last_read_seq);
@@ -1650,36 +1642,14 @@ server.registerTool("catch_up", {
1650
1642
  // dispatch can mutate `session` (the persona binding, the active room)
1651
1643
  // while a wait sleeps, so everything below runs off these captured
1652
1644
  // values.
1653
- let agentId;
1654
- let epoch;
1655
- let roomId;
1656
- let roomName;
1657
- if (room !== undefined) {
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());
1663
- const target = store.resolveRoom(room);
1664
- if (!target) {
1665
- return fail(`no room "${room}". Use list_rooms to see options.`);
1666
- }
1667
- if (!store.getMembership(target.id, agentId)) {
1668
- return fail(`you have never joined room "${target.name}", so there is no read position to advance; join_room it first`);
1669
- }
1670
- roomId = target.id;
1671
- roomName = target.name;
1672
- }
1673
- else {
1674
- ({ agentId, epoch, roomId } = requireActive());
1675
- roomName = store.getRoom(roomId)?.name ?? null;
1676
- }
1677
- touchCapturedRoom(roomId, agentId, epoch);
1645
+ // An explicit room leaves the active room unchanged.
1646
+ const { agentId, roomId, roomName } = resolveTargetRoom(room);
1647
+ touchCapturedRoom(roomId, agentId);
1678
1648
  const signal = extra?.signal;
1679
1649
  // max_bytes bounds the COMPLETE JSON text returned to the MCP client,
1680
- // not merely ChatStore.catchUp's inner object. v0.9 added routing fields
1681
- // and v0.10 added wait fields after the store had spent the full budget;
1682
- // an advancing page could then be rejected after its marker committed.
1650
+ // not merely ChatStore.catchUp's inner object. Routing and optional wait
1651
+ // fields sit outside that store result; without reserving them, an
1652
+ // advancing page could be rejected after its marker committed.
1683
1653
  // Reserve their exact serialized cost BEFORE the advancing transaction.
1684
1654
  const responseIdentity = {
1685
1655
  agent_id: agentId,
@@ -1709,15 +1679,15 @@ server.registerTool("catch_up", {
1709
1679
  ...result,
1710
1680
  ...extraFields,
1711
1681
  });
1712
- const advancingRead = (includeUnreadSummary) => store.catchUp(roomId, agentId, limit ?? 50, preview_chars, storeMaxBytes, epoch,
1682
+ const advancingRead = (includeUnreadSummary) => store.catchUp(roomId, agentId, limit ?? 50, preview_chars, storeMaxBytes,
1713
1683
  // rooms_with_unread on an empty read.
1714
1684
  includeUnreadSummary
1715
1685
  ? { priorityOnly: priority_only === true }
1716
1686
  : null);
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.
1687
+ // Retirement is the only way this call's authority can change under it.
1688
+ // Every advancingRead re-verifies the captured identity's live state
1689
+ // inside its own transaction, so a fenced read throws persona_lost rather
1690
+ // than consuming from a position this call did not capture.
1721
1691
  const roomDeletedResult = (duringWait = false) => {
1722
1692
  // A delete racing the active-room read invalidates that active route.
1723
1693
  // Do not clear a different room selected by a concurrent join, nor the
@@ -1725,7 +1695,7 @@ server.registerTool("catch_up", {
1725
1695
  if (session.roomId === roomId && session.agentId === agentId) {
1726
1696
  session.roomId = null;
1727
1697
  }
1728
- return fail(`room "${roomName ?? roomId}" was deleted while ${duringWait ? "waiting" : "reading"}; nothing was read. list_rooms shows what still exists.`);
1698
+ return fail(`room "${roomName}" was deleted while ${duringWait ? "waiting" : "reading"}; nothing was read. list_rooms shows what still exists.`);
1729
1699
  };
1730
1700
  // Abort boundary rule for everything below: once an abort has been
1731
1701
  // observed, NO advancing transaction may run.
@@ -1752,13 +1722,13 @@ server.registerTool("catch_up", {
1752
1722
  // Presence lease: when its best-effort write succeeds, `watching`
1753
1723
  // records that this call was open. TTL bounds a hard-kill ghost; a lease
1754
1724
  // 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}`;
1725
+ // Keyed by persona and room. An agent_id has exactly one tenure, so the
1726
+ // cleanup in the finally below can only ever reach this identity's own
1727
+ // row; a successor identity is a DIFFERENT id with its own key.
1728
+ const leaseKey = roomKey(roomId, agentId);
1759
1729
  openWaits.set(leaseKey, (openWaits.get(leaseKey) ?? 0) + 1);
1760
1730
  try {
1761
- store.beginWaitLease(roomId, agentId, epoch, waitSeconds + WAIT_LEASE_GRACE_SECONDS);
1731
+ store.beginWaitLease(roomId, agentId, waitSeconds + WAIT_LEASE_GRACE_SECONDS);
1762
1732
  }
1763
1733
  catch { }
1764
1734
  try {
@@ -1773,16 +1743,16 @@ server.registerTool("catch_up", {
1773
1743
  break;
1774
1744
  // Self-throttled heartbeat: a genuinely-waiting agent reads as
1775
1745
  // `active` to peers instead of indistinguishable from a dormant one.
1776
- touchCapturedRoom(roomId, agentId, epoch);
1746
+ touchCapturedRoom(roomId, agentId);
1777
1747
  let unread;
1778
1748
  try {
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);
1749
+ // Fenced: a retirement surfaces here, within one probe interval,
1750
+ // instead of waiting for traffic or the deadline.
1751
+ unread = store.unreadProbe(roomId, agentId);
1782
1752
  }
1783
1753
  catch (e) {
1784
1754
  // A lost persona is NOT a deleted room. Rethrow it before the
1785
- // room-existence fallback, or a takeover would be reported as
1755
+ // room-existence fallback, or a retirement would be reported as
1786
1756
  // "the room was deleted".
1787
1757
  if (e instanceof PersonaLostError)
1788
1758
  throw e;
@@ -1839,7 +1809,7 @@ server.registerTool("catch_up", {
1839
1809
  else {
1840
1810
  openWaits.delete(leaseKey);
1841
1811
  try {
1842
- store.endWaitLease(roomId, agentId, epoch);
1812
+ store.endWaitLease(roomId, agentId);
1843
1813
  }
1844
1814
  catch { }
1845
1815
  }
@@ -1901,10 +1871,10 @@ server.registerTool("my_mentions", {
1901
1871
  // my_mentions needs a PERSONA but no active room: it reads across every
1902
1872
  // room the persona is present in, which is exactly what makes it usable
1903
1873
  // after a leave. So the remedy is binding, not joining.
1904
- const { agentId, epoch } = requirePersona();
1874
+ const { agentId } = requirePersona();
1905
1875
  // Reserve the disclosure's exact serialized cost BEFORE the bounded read,
1906
1876
  // so a caller's max_bytes still holds on the fenced path.
1907
- const lost = lossDisclosure(agentId, epoch);
1877
+ const lost = lossDisclosure(agentId);
1908
1878
  const requested = max_bytes ?? DEFAULT_MAX_BYTES;
1909
1879
  const reserve = disclosureReserve(lost);
1910
1880
  return ok({
@@ -1965,7 +1935,8 @@ server.registerTool("pending_work", {
1965
1935
  });
1966
1936
  server.registerTool("wait_for_messages", {
1967
1937
  title: "Wait for new messages (background poller)",
1968
- description: "Return (do not run) one childless background poller command. It watches " +
1938
+ description: "Returns one childless background poller command for YOU to run as a " +
1939
+ "background task; this server does not execute it. It watches " +
1969
1940
  "all joined rooms or one `room`; `mentions_only` narrows it. Generated " +
1970
1941
  "commands exit 0 on hit or quiet deadline: parse stdout `has_updates`. " +
1971
1942
  "Use catch_up({wait_seconds}) for an in-turn blocking read.",
@@ -2003,16 +1974,15 @@ server.registerTool("wait_for_messages", {
2003
1974
  }, async ({ room, mentions_only, timeout, interval }) => {
2004
1975
  try {
2005
1976
  touchSession();
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));
2015
- }
1977
+ const { agentId } = requirePersona();
1978
+ // Check the binding with one indexed primary-key lookup BEFORE handing out
1979
+ // a command. The poller keeps its own per-probe check for a retirement
1980
+ // that lands after generation, but a command minted for an already-dead
1981
+ // identity can only exit retired_identity. Refuse it here instead of
1982
+ // sending the caller away to arm a watcher that cannot run.
1983
+ const loss = store.personaLoss({ agentId, connectionId });
1984
+ if (loss !== null)
1985
+ return failFrom(new PersonaLostError(agentId, loss));
2016
1986
  let roomArg;
2017
1987
  if (room !== undefined) {
2018
1988
  // PRESENT membership required, not merely an existing row. A watcher on
@@ -2035,15 +2005,21 @@ server.registerTool("wait_for_messages", {
2035
2005
  }
2036
2006
  roomArg = String(target.id);
2037
2007
  }
2038
- else if (store.presentRoomCount(agentId) === 0) {
2039
- // Unscoped watch of ALL your rooms, but you are in none: the poller
2040
- // would exit 2 immediately. Say so rather than emit a doomed command.
2041
- return fail("you are not present in any room, so there is nothing to watch; join_room first, or pass a room you have joined");
2008
+ else {
2009
+ const memberships = store.roomMembershipCounts(agentId);
2010
+ if (memberships.total === 0) {
2011
+ return fail("no_room_memberships: you have not joined any existing room, so " +
2012
+ "there is nothing to watch; use list_rooms and join_room first");
2013
+ }
2014
+ if (memberships.present === 0) {
2015
+ return fail("left_all_rooms: you have LEFT every room you joined, so there is " +
2016
+ "nothing to watch; call join_room to rejoin one -- its read " +
2017
+ "position and role are preserved");
2018
+ }
2042
2019
  }
2043
2020
  const command = pollerCmd(agentId, {
2044
2021
  room: roomArg,
2045
2022
  mentionsOnly: mentions_only,
2046
- epoch,
2047
2023
  timeoutSec: timeout,
2048
2024
  intervalSec: interval,
2049
2025
  });
@@ -2052,13 +2028,14 @@ server.registerTool("wait_for_messages", {
2052
2028
  run_as: "background process (do not wait for it inline)",
2053
2029
  how_to: "Run `command` in the background. On exit 0, parse stdout: " +
2054
2030
  "has_updates:true names the room to catch_up; has_updates:false is a " +
2055
- "normal quiet deadline. Exit 2 is an error/duplicate watcher. Re-arm " +
2056
- "only if still needed. The exit is only an OS signal; whether it " +
2057
- "wakes YOU depends on the harness.",
2031
+ "normal quiet deadline. Exit 2 is an error, duplicate watcher, or a " +
2032
+ "terminal room/binding state; inspect stderr before re-arming. The " +
2033
+ "exit is only an OS signal; whether it wakes YOU depends on the harness.",
2058
2034
  exit_codes: {
2059
2035
  "0": "normal completion; inspect stdout has_updates",
2060
2036
  "124": "quiet timeout only for direct CLI without --ok-on-timeout",
2061
- "2": "error or equivalent watcher already running",
2037
+ "2": "error, live-PID watcher lock, retired_identity, left_room, " +
2038
+ "left_all_rooms, or no_room_memberships; inspect stderr",
2062
2039
  },
2063
2040
  baselined: false,
2064
2041
  single_process: true,
@@ -2110,8 +2087,8 @@ server.registerTool("read_history", {
2110
2087
  }, async ({ limit, before_seq, preview_chars, max_bytes }) => {
2111
2088
  try {
2112
2089
  touchSession();
2113
- const { agentId, epoch, roomId } = requireActive();
2114
- const lost = lossDisclosure(agentId, epoch);
2090
+ const { agentId, roomId } = requireActive();
2091
+ const lost = lossDisclosure(agentId);
2115
2092
  const requested = max_bytes ?? DEFAULT_MAX_BYTES;
2116
2093
  return ok({
2117
2094
  ...lost,
@@ -2139,8 +2116,8 @@ server.registerTool("mark_read", {
2139
2116
  }, async ({ seq }) => {
2140
2117
  try {
2141
2118
  touchSession();
2142
- const { agentId, epoch, roomId } = requireActive();
2143
- return ok(store.markRead(roomId, agentId, epoch, seq));
2119
+ const { agentId, roomId } = requireActive();
2120
+ return ok(store.markRead(roomId, agentId, seq));
2144
2121
  }
2145
2122
  catch (e) {
2146
2123
  return failFrom(e);
@@ -2183,13 +2160,13 @@ server.registerTool("get_message", {
2183
2160
  }, async ({ room, seq, offset, max_chars }) => {
2184
2161
  try {
2185
2162
  touchSession();
2186
- const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2187
- touchCapturedRoom(roomId, agentId, epoch);
2163
+ const { agentId, roomId, roomName } = resolveJoinedRoom(room);
2164
+ touchCapturedRoom(roomId, agentId);
2188
2165
  const msg = store.getMessage(roomId, seq, offset ?? 0, max_chars);
2189
2166
  if (!msg) {
2190
- return fail(`no message ${seq} in room "${roomName ?? roomId}"`);
2167
+ return fail(`no message ${seq} in room "${roomName}"`);
2191
2168
  }
2192
- return ok({ ...lossDisclosure(agentId, epoch), ...msg });
2169
+ return ok({ ...lossDisclosure(agentId), ...msg });
2193
2170
  }
2194
2171
  catch (e) {
2195
2172
  return failFrom(e);
@@ -2230,13 +2207,13 @@ server.registerTool("get_thread", {
2230
2207
  }, async ({ room, seq, max_depth, preview_chars }) => {
2231
2208
  try {
2232
2209
  touchSession();
2233
- const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2234
- touchCapturedRoom(roomId, agentId, epoch);
2210
+ const { agentId, roomId, roomName } = resolveJoinedRoom(room);
2211
+ touchCapturedRoom(roomId, agentId);
2235
2212
  const thread = store.getThread(roomId, seq, max_depth ?? 3, preview_chars);
2236
2213
  if (!thread) {
2237
- return fail(`no message ${seq} in room "${roomName ?? roomId}"`);
2214
+ return fail(`no message ${seq} in room "${roomName}"`);
2238
2215
  }
2239
- return ok({ ...lossDisclosure(agentId, epoch), ...thread });
2216
+ return ok({ ...lossDisclosure(agentId), ...thread });
2240
2217
  }
2241
2218
  catch (e) {
2242
2219
  return failFrom(e);
@@ -2255,9 +2232,9 @@ server.registerTool("set_room_intro", {
2255
2232
  }, async ({ text }) => {
2256
2233
  try {
2257
2234
  touchSession();
2258
- const { agentId, epoch, roomId } = requireActive();
2235
+ const { agentId, roomId } = requireActive();
2259
2236
  const value = text.length > 0 ? text : null;
2260
- store.setPinned(roomId, agentId, epoch, value);
2237
+ store.setPinned(roomId, agentId, value);
2261
2238
  return ok({ room_id: roomId, pinned: value });
2262
2239
  }
2263
2240
  catch (e) {
@@ -2291,9 +2268,9 @@ server.registerTool("search_messages", {
2291
2268
  }, async ({ query, limit, offset }) => {
2292
2269
  try {
2293
2270
  touchSession();
2294
- const { agentId, epoch, roomId } = requireActive();
2271
+ const { agentId, roomId } = requireActive();
2295
2272
  return ok({
2296
- ...lossDisclosure(agentId, epoch),
2273
+ ...lossDisclosure(agentId),
2297
2274
  ...store.searchMessages(roomId, query, limit ?? 20, offset ?? 0),
2298
2275
  });
2299
2276
  }
@@ -2339,12 +2316,12 @@ server.registerTool("claim", {
2339
2316
  }, async ({ room, key, ttl_seconds, note }) => {
2340
2317
  try {
2341
2318
  touchSession();
2342
- const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2343
- touchCapturedRoom(roomId, agentId, epoch);
2319
+ const { agentId, roomId, roomName } = resolveTargetRoom(room);
2320
+ touchCapturedRoom(roomId, agentId);
2344
2321
  return ok({
2345
2322
  room_id: roomId,
2346
2323
  room_name: roomName,
2347
- ...store.claimResource(roomId, key, agentId, epoch, ttl_seconds ?? 900, note ?? null),
2324
+ ...store.claimResource(roomId, key, agentId, ttl_seconds ?? 900, note ?? null),
2348
2325
  });
2349
2326
  }
2350
2327
  catch (e) {
@@ -2368,12 +2345,12 @@ server.registerTool("release_claim", {
2368
2345
  }, async ({ room, key }) => {
2369
2346
  try {
2370
2347
  touchSession();
2371
- const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2372
- touchCapturedRoom(roomId, agentId, epoch);
2348
+ const { agentId, roomId, roomName } = resolveJoinedRoom(room);
2349
+ touchCapturedRoom(roomId, agentId);
2373
2350
  return ok({
2374
2351
  room_id: roomId,
2375
2352
  room_name: roomName,
2376
- ...store.releaseClaim(roomId, key, agentId, epoch),
2353
+ ...store.releaseClaim(roomId, key, agentId),
2377
2354
  });
2378
2355
  }
2379
2356
  catch (e) {
@@ -2416,11 +2393,11 @@ server.registerTool("list_claims", {
2416
2393
  }, async ({ room, limit, after_key }) => {
2417
2394
  try {
2418
2395
  touchSession();
2419
- const { agentId, epoch, roomId, roomName } = resolveJoinedRoom(room);
2420
- touchCapturedRoom(roomId, agentId, epoch);
2396
+ const { agentId, roomId, roomName } = resolveJoinedRoom(room);
2397
+ touchCapturedRoom(roomId, agentId);
2421
2398
  const { claims, total, next_key, size_trimmed } = store.listClaims(roomId, limit ?? 200, after_key ?? "");
2422
2399
  return ok({
2423
- ...lossDisclosure(agentId, epoch),
2400
+ ...lossDisclosure(agentId),
2424
2401
  room_id: roomId,
2425
2402
  room_name: roomName,
2426
2403
  claims,
@@ -2455,10 +2432,10 @@ server.registerTool("prune_messages", {
2455
2432
  }, async ({ keep_last, force }) => {
2456
2433
  try {
2457
2434
  touchSession();
2458
- const { agentId, epoch, roomId } = requireActive();
2435
+ const { agentId, roomId } = requireActive();
2459
2436
  return ok({
2460
2437
  room_id: roomId,
2461
- ...store.pruneMessages(roomId, agentId, epoch, keep_last, force ?? false),
2438
+ ...store.pruneMessages(roomId, agentId, keep_last, force ?? false),
2462
2439
  });
2463
2440
  }
2464
2441
  catch (e) {
@@ -2480,14 +2457,14 @@ server.registerTool("delete_room", {
2480
2457
  }, async ({ room, confirm }) => {
2481
2458
  try {
2482
2459
  touchSession();
2483
- const { agentId, epoch } = requirePersona();
2460
+ const { agentId } = requirePersona();
2484
2461
  const target = store.resolveRoom(room);
2485
2462
  if (!target)
2486
2463
  return fail(`no room "${room}"`);
2487
2464
  if (confirm !== true) {
2488
2465
  return fail(`pass confirm:true to delete room ${target.id} ("${target.name}")`);
2489
2466
  }
2490
- const result = store.deleteRoom(target.id, agentId, epoch);
2467
+ const result = store.deleteRoom(target.id, agentId);
2491
2468
  if (session.roomId === target.id) {
2492
2469
  session.roomId = null; // identity survives; only the room is gone
2493
2470
  }
@@ -2497,39 +2474,6 @@ server.registerTool("delete_room", {
2497
2474
  return failFrom(e);
2498
2475
  }
2499
2476
  });
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 = [
2509
- "amber", "brisk", "calm", "clever", "cobalt", "copper", "deft", "eager",
2510
- "fern", "gilded", "hardy", "ivory", "jade", "keen", "lucid", "mellow",
2511
- "nimble", "olive", "prime", "quiet", "rapid", "sable", "teal", "umber",
2512
- "vivid", "warm",
2513
- ];
2514
- const WORD_NOUNS = [
2515
- "otter", "falcon", "cedar", "harbor", "lynx", "maple", "comet", "delta",
2516
- "ember", "fjord", "grove", "heron", "inlet", "kite", "larch", "mesa",
2517
- "nimbus", "onyx", "pike", "quartz", "ridge", "summit", "tundra", "vale",
2518
- "willow", "yarrow",
2519
- ];
2520
- /** Uniform index into xs, drawn from CSPRNG bytes with rejection sampling so a
2521
- * non-multiple list length cannot bias the draw. */
2522
- function pick(xs) {
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
- }
2529
- }
2530
- function makeResumeWord() {
2531
- return `${pick(WORD_ADJECTIVES)}-${pick(WORD_NOUNS)}-${randomBytes(2).readUInt16BE(0)}`;
2532
- }
2533
2477
  /**
2534
2478
  * Normalize one component of a canonical persona id: lowercase, non-alphanumeric
2535
2479
  * runs collapsed to single hyphens, trimmed. "Claude Opus 4.5" -> "claude-opus-4-5".
@@ -2542,41 +2486,41 @@ function slug(s) {
2542
2486
  .replace(/^-+|-+$/g, "");
2543
2487
  }
2544
2488
  /**
2545
- * Build and atomically claim the canonical persona id
2489
+ * Build a supplier of canonical persona id candidates
2546
2490
  * `brand-model-vversion-shorttoken`.
2547
2491
  *
2548
2492
  * 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.
2493
+ * distinguishable -- a common case, since two processes of one model routinely
2494
+ * work in one room, and neither can be handed the other's nickname. The token
2495
+ * is NEVER omitted, not even when no matching tuple exists yet, because a
2496
+ * second process with that tuple is normal rather than exceptional.
2497
+ *
2498
+ * The supplier only proposes. Claiming is the store's job, inside the identify
2499
+ * transaction, so a token collision retries under the same transaction that
2500
+ * would otherwise commit half a transition.
2553
2501
  */
2554
- function createPersona(brand, model, version, description) {
2502
+ const PERSONA_TOKEN_CHARS = 6;
2503
+ function personaIdAllocator(brand, model, version) {
2555
2504
  const parts = [slug(brand), slug(model), `v${slug(version)}`];
2556
2505
  if (parts[0] === "" || parts[1] === "" || parts[2] === "v") {
2557
2506
  throw new Error("brand, model, and version must each contain at least one letter or digit");
2558
2507
  }
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) {
2508
+ let base = parts.join("-");
2509
+ // `human-` is reserved for web participants. A tuple whose slug lands there
2510
+ // (brand "Human", say) is escaped DETERMINISTICALLY rather than by redrawing
2511
+ // the token: the collision is in the base, so no number of new tokens can
2512
+ // clear it, and a retry loop would spin until it gave up.
2513
+ if (base.startsWith("human-"))
2514
+ base = `llm-${base}`;
2515
+ const full = base.length + 1 + PERSONA_TOKEN_CHARS;
2516
+ if (full > MAX_AGENT_ID_CHARS) {
2565
2517
  throw new Error(`brand/model/version are too long: the canonical persona id ` +
2566
- `"${base}-<token>" needs ${base.length + 1 + TOKEN_CHARS} characters ` +
2518
+ `"${base}-<token>" needs ${full} characters ` +
2567
2519
  `and the limit is ${MAX_AGENT_ID_CHARS}. Shorten them by at least ` +
2568
- `${base.length + 1 + TOKEN_CHARS - MAX_AGENT_ID_CHARS} characters ` +
2520
+ `${full - MAX_AGENT_ID_CHARS} characters ` +
2569
2521
  `(punctuation collapses to single hyphens, so it still counts).`);
2570
2522
  }
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");
2523
+ return () => `${base}-${randomBytes(PERSONA_TOKEN_CHARS / 2).toString("hex")}`;
2580
2524
  }
2581
2525
  function dedupe(xs) {
2582
2526
  return [...new Set(xs)];
@@ -2606,7 +2550,7 @@ function shutdown(code, message) {
2606
2550
  return shutdownPromise;
2607
2551
  process.exitCode = code;
2608
2552
  if (message)
2609
- process.stderr.write(`${message}\n`);
2553
+ diag("fatal", { reason: message });
2610
2554
  shutdownPromise = (async () => {
2611
2555
  // Keep a hard bound even if a third-party transport stops honoring close.
2612
2556
  const forcedExit = setTimeout(() => process.exit(code), 3_000);
@@ -2616,6 +2560,33 @@ function shutdown(code, message) {
2616
2560
  // shared store, so EOF cannot leave a wait alive or advance afterward.
2617
2561
  await server.close().catch(() => undefined);
2618
2562
  await Promise.allSettled(activeToolRequests);
2563
+ // Retire this process's identity AFTER in-flight requests have finished
2564
+ // (so nothing is still writing under it) and BEFORE the store closes (so
2565
+ // the transaction can still run). Guarded on the exact agent id and nonce
2566
+ // this process recorded: if either differs, nothing is mutated.
2567
+ // A shutdown that matched on the id alone could retire a replacement
2568
+ // identity that a later incarnation already established.
2569
+ //
2570
+ // Best effort by design. A hard kill never reaches this, leaving the row
2571
+ // bound and its memberships present. That is an accepted limitation, not
2572
+ // an oversight: no timeout can tell an idle live CLI from a dead one, so
2573
+ // a reaper would retire live processes.
2574
+ const binding = expectedBinding();
2575
+ if (binding) {
2576
+ try {
2577
+ const retired = store.retireConnection(binding);
2578
+ diag(retired ? "shutdown_retired" : "shutdown_retire_skipped", {
2579
+ agent_id: binding.agentId,
2580
+ reason: retired ? null : "binding did not match the stored row",
2581
+ });
2582
+ }
2583
+ catch (e) {
2584
+ diag("shutdown_retire_failed", {
2585
+ agent_id: binding.agentId,
2586
+ error: asMessage(e),
2587
+ });
2588
+ }
2589
+ }
2619
2590
  try {
2620
2591
  store.close();
2621
2592
  }
@@ -2648,13 +2619,38 @@ async function main() {
2648
2619
  void shutdown(1, `fatal: stdout disconnected: ${asMessage(error)}`);
2649
2620
  });
2650
2621
  process.stderr.once("error", () => void shutdown(1));
2622
+ // Client name/version exist only once the client has sent initialize, which
2623
+ // is why this is recorded here rather than at startup. It identifies the HOST
2624
+ // (e.g. claude-code), never the active model.
2625
+ server.server.oninitialized = () => {
2626
+ const info = server.server.getClientVersion();
2627
+ // Cap at CAPTURE, not only at serialization. A client is free to send a
2628
+ // maximum-frame name, and storing it whole would keep that string resident
2629
+ // for the life of the process just to slice it on every later record.
2630
+ if (info) {
2631
+ clientInfo = {
2632
+ name: String(info.name).slice(0, DIAG_FIELD_CHARS),
2633
+ version: String(info.version).slice(0, DIAG_FIELD_CHARS),
2634
+ };
2635
+ }
2636
+ diag("initialized", {});
2637
+ };
2651
2638
  const transport = new StdioServerTransport(boundedInput, process.stdout);
2652
2639
  await server.connect(transport);
2653
2640
  process.stdin.once("error", (error) => boundedInput.destroy(error));
2654
2641
  process.stdin.pipe(boundedInput);
2655
2642
  // stdio transport: do not write to stdout; it carries the JSON-RPC stream.
2656
- process.stderr.write(`agent-chat-mcp ready (db: ${store.path})\n`);
2643
+ // The database path is deliberately NOT logged: it is deployment detail with
2644
+ // no correlation value, and this record goes to a file someone may share.
2645
+ diag("ready", {});
2657
2646
  }
2647
+ // SIGINT/SIGTERM go through the SAME bounded shutdown path as stdin EOF, so a
2648
+ // terminated process retires its identity instead of leaving a bound row with
2649
+ // present memberships behind. Without these, every Ctrl-C and every supervisor
2650
+ // stop produced the ghost that only a hard kill is supposed to produce.
2651
+ // SIGKILL remains uncatchable and stays the accepted crash case.
2652
+ process.once("SIGINT", () => void shutdown(130));
2653
+ process.once("SIGTERM", () => void shutdown(143));
2658
2654
  main().catch((e) => {
2659
2655
  void shutdown(1, `fatal: ${asMessage(e)}`);
2660
2656
  });