multi-agent-collaboration-mcp 0.12.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 ADDED
@@ -0,0 +1,2241 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { randomUUID } from "node:crypto";
6
+ import { createRequire } from "node:module";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { readFileSync } from "node:fs";
10
+ import { z } from "zod";
11
+ import { ChatStore, DEFAULT_MAX_BYTES, MAX_CLIENT_MESSAGE_ID_CHARS, MAX_CROSSED_PREVIEW_CHARS, MAX_MESSAGE_BODY_BYTES, MIN_CATCH_UP_RESULT_BUDGET, } from "./db.js";
12
+ import { BoundedLineTransform, MAX_MCP_FRAME_BYTES, } from "./bounded-lines.js";
13
+ import { stringifyWellFormedJson } from "./unicode.js";
14
+ // The watcher is a sibling entry. Production executes compiled poller.js;
15
+ // `npm run dev` executes the TypeScript sibling through the already-installed
16
+ // tsx CLI instead of returning the nonexistent src/poller.js path.
17
+ const THIS_MODULE = fileURLToPath(import.meta.url);
18
+ const MODULE_DIR = dirname(THIS_MODULE);
19
+ const POLLER_COMMAND = THIS_MODULE.endsWith(".ts")
20
+ ? [
21
+ process.execPath,
22
+ createRequire(import.meta.url).resolve("tsx/cli"),
23
+ join(MODULE_DIR, "poller.ts"),
24
+ ]
25
+ : [process.execPath, join(MODULE_DIR, "poller.js")];
26
+ // Single-quote paths for the copy-pasteable poller command: double quotes
27
+ // would let a path containing $() or backticks execute when pasted into a
28
+ // shell. Embedded single quotes are escaped with the '\'' idiom.
29
+ function shq(s) {
30
+ return `'${s.replace(/'/g, "'\\''")}'`;
31
+ }
32
+ /**
33
+ * The exact background-poller invocation for a known agent id, safe to run
34
+ * verbatim: every value is shell-quoted (self-asserted ids may contain
35
+ * quotes, spaces, or $(), which a hand-substituted placeholder cannot
36
+ * survive). ONE hardcoded database is the rule: the poller resolves the same
37
+ * built-in default on its own, and the testing-only override (AGENT_CHAT_DB /
38
+ * --db) is deliberately NEVER advertised to clients -- a client must not
39
+ * learn from any tool text that another database is possible.
40
+ */
41
+ function pollerCmd(agentId, opts = {}) {
42
+ let cmd = `${POLLER_COMMAND.map(shq).join(" ")} --agent ${shq(agentId)}`;
43
+ // Generated commands belong to this MCP session. If the client reconnects
44
+ // and this server exits, its old session-nonce watcher retires within five
45
+ // seconds instead of accumulating until a long timeout. Direct CLI commands
46
+ // can omit --owner-pid when independent lifetime is intentional.
47
+ cmd += ` --owner-pid ${shq(String(process.pid))}`;
48
+ if (opts.room !== undefined)
49
+ cmd += ` --room ${shq(opts.room)}`;
50
+ // Both scoped and all-room watches resolve this session's CURRENT cursor on
51
+ // every probe. Never bake a point-in-time --since value into a restartable
52
+ // command: once crossed, that stale baseline fires forever.
53
+ if (opts.session !== undefined) {
54
+ cmd += ` --session ${shq(opts.session)}`;
55
+ }
56
+ // Baked-in loop knobs, so callers stop hand-editing the command string
57
+ // (the historical -32602 friction: the values LOOK like tool params).
58
+ if (opts.timeoutSec !== undefined) {
59
+ cmd += ` --timeout ${shq(String(opts.timeoutSec))}`;
60
+ }
61
+ if (opts.intervalSec !== undefined) {
62
+ cmd += ` --interval ${shq(String(opts.intervalSec))}`;
63
+ }
64
+ // Generated commands treat an expected quiet deadline as successful
65
+ // completion. `has_updates` in stdout distinguishes timeout from a hit;
66
+ // direct legacy CLI invocations without this flag retain exit 124.
67
+ cmd += ` --ok-on-timeout`;
68
+ if (opts.mentionsOnly)
69
+ cmd += ` --mentions-only`;
70
+ return cmd;
71
+ }
72
+ /** A client that abandons a request without delivering cancellation can leave
73
+ * the server able to advance a marker into an undeliverable response. Keep the
74
+ * safe default below even pessimistic host deadlines; longer waits are an
75
+ * explicit deployment choice, bounded by a small hard ceiling. */
76
+ const DEFAULT_WAIT_CAP_SECONDS = 25;
77
+ const HARD_WAIT_CAP_SECONDS = 120;
78
+ function configuredWaitCapSeconds() {
79
+ const raw = process.env.AGENT_CHAT_MAX_WAIT_SECONDS;
80
+ if (raw === undefined || raw === "")
81
+ return DEFAULT_WAIT_CAP_SECONDS;
82
+ if (!/^\d+$/.test(raw)) {
83
+ throw new Error(`AGENT_CHAT_MAX_WAIT_SECONDS must be an integer from 1 to ${HARD_WAIT_CAP_SECONDS}`);
84
+ }
85
+ const value = Number(raw);
86
+ if (!Number.isSafeInteger(value) || value < 1 || value > HARD_WAIT_CAP_SECONDS) {
87
+ throw new Error(`AGENT_CHAT_MAX_WAIT_SECONDS must be an integer from 1 to ${HARD_WAIT_CAP_SECONDS}`);
88
+ }
89
+ return value;
90
+ }
91
+ const WAIT_CAP_SECONDS = configuredWaitCapSeconds();
92
+ // Validate process-level configuration before opening or migrating the shared
93
+ // database. A typo must fail without mutating production state first.
94
+ const store = new ChatStore();
95
+ const INSTRUCTIONS = `Agent Chat is a local SQLite ledger. Start list_rooms -> join_room -> catch_up. catch_up advances one room; my_mentions peeks across rooms. priority_only is explicitly lossy. For out-of-turn watching, wait_for_messages returns the background poller command. server_info holds routing/shared budgets; each tool schema states its cap.`;
96
+ // The layered operating manual, served by server_info: stable reference
97
+ // detail that would otherwise bloat every tools/list. Tool descriptions keep
98
+ // only their unique semantics and point here.
99
+ const MANUAL = `OPERATING MANUAL
100
+
101
+ ROUTING
102
+ - catch_up reads ONE room and ADVANCES your read marker; ordinary calls are lossless and never advance past an undelivered message from another author. Because own posts are never returned, the marker may normalize across an own-only suffix before the next peer row (so an empty page can still say advanced:true). room:<id|name> reads another joined room without changing the active room. Explicit priority_only:true is LOSSY backlog triage: it returns priority:true rows plus every directed mention/reply, advances over lower-priority rows through cutoff_seq, and reports skipped_count + qualifying_remaining. It cannot be combined with wait_seconds. An empty read includes a bounded rooms_with_unread list; inspect rooms_with_unread_truncated before treating it as exhaustive.
103
+ - my_mentions: cross-room inbox of unread directed at you (mentions + replies to your messages); never moves markers; entries clear when you read their room; page with after_id = next_after_id; by_room reports each returned room's TOTAL unread; inspect by_room_truncated before treating it as exhaustive.
104
+ - read_history browses without moving markers. mark_read moves the marker without reading (omit seq to jump to latest; a LOWER seq re-exposes messages to catch_up).
105
+
106
+ IN-CALL WAIT
107
+ - catch_up wait_seconds (0..${WAIT_CAP_SECONDS} effective max) blocks that one call until a message from another agent lands in the target room, then returns it and advances. The safe default max is 25s; an operator may set AGENT_CHAT_MAX_WAIT_SECONDS up to 120 only after measuring end-to-end behavior on that host. wait_seconds bounds the polling deadline, not total RPC wall time: SQLite contention, lease cleanup, and serialization can add several bounded busy-timeout windows. On timeout: timed_out:true, call_again:true, rooms_with_unread. Normal hit/timeout responses carry waited_ms; cancellation/deletion errors may not.
108
+ - The best-effort watching lease expires wait_seconds+5s after it begins. Raising the cap therefore also lengthens the maximum stale watching:true window after a hard-killed host; this is part of the operator opt-in.
109
+ - While your wait is open and its lease write succeeds, peers see watching:true for you (list_agents, post_message recipients): evidence that a blocking call was open, not a delivery guarantee. It drops on normal return/cancellation; TTL bounds a hard-kill ghost. A detached poller never produces it.
110
+ - The wait holds your turn open, so it fits "I am waiting for a reply and have nothing else to do". To be notified while doing other work, or for watches longer than the cap, use the background poller.
111
+
112
+ SIZE AND PAGING
113
+ - 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.
114
+ - 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.
115
+
116
+ POSTING
117
+ - crossed counts ALL unread from others past your marker at post time (old backlog included, not only mid-composition arrivals); crossed_directed says how many are aimed at you; crossed_range gives the seq span. If crossed > 0, catch_up before acting on replies. crossed_preview_chars opts into bounded previews of the crossed messages in the same response.
118
+ - Dispositive posts (verdicts, commissions, dispositions): if_last_read_seq is a conditional post -- rejected (posted:false) if ANYTHING from others landed past your token, with bounded crossed previews returned; call catch_up for the complete delta before retrying. If pruning removed evidence after the token, it rejects conservatively with rejected:evidence_pruned and no invented previews. A token ahead of the target room's effective cursor is invalid and fails before posting. client_message_id makes an exact lost-response retry return the original seq instead of inserting twice; its guarantee lasts while that message is retained. Repeat the same explicit room or expected_room on retry so active-room drift cannot create a post in another room; a deduplicated response does not replay the original crossed/recipient snapshot, so catch_up for current state. room: posts to a named joined room without switching the active room; expected_room asserts which room is active. Never use the CAS on routine traffic: crossing is normal, the CAS is for posts whose validity depends on having read everything.
119
+ - 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.
120
+ - supersedes_seq corrects YOUR OWN earlier message; readers see superseded_by on it. reply_to_seq threads; the log stays flat and globally ordered.
121
+ - priority:true marks an immutable high-signal checkpoint for priority-only catch-up. Use it sparingly; correct a priority post with a new priority post + supersedes_seq rather than mutating history.
122
+ - claim/release_claim: atomic single-winner advisory locks with TTL expiry (a crashed holder cannot block forever). Claims are mutual exclusion between live writers; they do not verify content.
123
+
124
+ MULTIPLE SESSIONS, ONE IDENTITY
125
+ - The default shared cursor splits the backlog across concurrent sessions (work-queue style). join_room cursor:'private' gives THIS session an independent read position. The poller command carries --session and resolves that session's current cursor on each probe; it never freezes a --since baseline.
126
+
127
+ BACKGROUND POLLER
128
+ - Run the command join_room/wait_for_messages return as a BACKGROUND task. One Node process holds one SQLite connection and runs one indexed LIMIT 1 probe after each sleep; it launches no children. Generated commands exit 0 for either a hit or quiet deadline: parse stdout has_updates true/false. Direct CLI calls without --ok-on-timeout retain exit 124 on timeout. Exit 2 is an error or equivalent watcher. Options: --interval <sec> (minimum/default 5), --timeout <sec> (default 1200, finite), --ok-on-timeout, --mentions-only, --room <id|name>. Your own posts never wake it.
129
+ - The poller is an OS-level detector: its exit does NOT by itself schedule your next turn. Whether you are actually woken depends on your harness's background-task contract; do not report "watcher active" as evidence you will see a message.
130
+
131
+ RETENTION
132
+ - prune_messages deletes old messages (refuses while any non-author member has them unread; force overrides). A room seq you cite in a document is durable only as long as nobody prunes past it.`;
133
+ // One stdio server process serves one agent. We remember its identity and
134
+ // active room for the session so the agent need not repeat them on every call.
135
+ const session = {
136
+ agentId: null,
137
+ roomId: null,
138
+ privateRooms: new Set(),
139
+ };
140
+ /** Key for the per-(room, identity) private-cursor mode set. \u0000 cannot
141
+ * appear in an agent id (control chars are rejected), so keys never collide. */
142
+ function privKey(roomId, agentId) {
143
+ return `${roomId}\u0000${agentId}`;
144
+ }
145
+ // Distinguishes this process's private read cursor (join_room cursor:'private')
146
+ // from other sessions running under the same agent_id.
147
+ const SESSION_NONCE = randomUUID();
148
+ /** The session-cursor key for ACTIVE-room store calls: the nonce when the
149
+ * active room was joined with a private cursor UNDER THE CURRENT IDENTITY,
150
+ * else null. */
151
+ function cursorId() {
152
+ return session.roomId !== null &&
153
+ session.agentId !== null &&
154
+ session.privateRooms.has(privKey(session.roomId, session.agentId))
155
+ ? SESSION_NONCE
156
+ : null;
157
+ }
158
+ // Compact (not pretty-printed) JSON: bulk reads run against a hard client
159
+ // output cap, and indentation wastes budget that could carry messages.
160
+ function ok(data) {
161
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
162
+ }
163
+ function fail(message) {
164
+ return {
165
+ content: [{ type: "text", text: JSON.stringify({ error: message }, null, 2) }],
166
+ isError: true,
167
+ };
168
+ }
169
+ /** Cadence of the non-advancing unread probe during a blocking wait. */
170
+ const WAIT_PROBE_INTERVAL_MS = 500;
171
+ /** Bound aggregate timer/SQLite pressure when a client accidentally dispatches
172
+ * many blocking catch_up calls in parallel. */
173
+ const MAX_CONCURRENT_WAITS = 4;
174
+ let activeBlockingWaits = 0;
175
+ /** Wait-lease TTL grace past the deadline, covering the final advancing
176
+ * read; a hard-killed process's lease self-expires this soon after. */
177
+ const WAIT_LEASE_GRACE_SECONDS = 5;
178
+ /** Sleep that wakes EARLY on abort (never rejects; callers re-check
179
+ * signal.aborted, which is also the correct behavior for an already-aborted
180
+ * signal). The listener is removed on normal expiry, so a long-lived signal
181
+ * does not accumulate one listener per tick. */
182
+ function abortableSleep(ms, signal) {
183
+ return new Promise((resolve) => {
184
+ if (!signal) {
185
+ setTimeout(resolve, Math.max(0, ms));
186
+ return;
187
+ }
188
+ if (signal.aborted) {
189
+ resolve();
190
+ return;
191
+ }
192
+ const onAbort = () => {
193
+ clearTimeout(t);
194
+ resolve();
195
+ };
196
+ const t = setTimeout(() => {
197
+ signal.removeEventListener("abort", onAbort);
198
+ resolve();
199
+ }, Math.max(0, ms));
200
+ signal.addEventListener("abort", onAbort, { once: true });
201
+ });
202
+ }
203
+ function requireActive() {
204
+ if (session.agentId === null || session.roomId === null) {
205
+ throw new Error("join a room first with join_room");
206
+ }
207
+ // The room may have been deleted by another server process; the local session
208
+ // would otherwise stay pointed at it and fail later with a low-level DB error.
209
+ // The identity survives: only the active room is gone.
210
+ if (!store.getRoom(session.roomId)) {
211
+ const stale = session.roomId;
212
+ session.roomId = null;
213
+ throw new Error(`active room ${stale} no longer exists (deleted); rejoin with join_room`);
214
+ }
215
+ return { agentId: session.agentId, roomId: session.roomId };
216
+ }
217
+ /** Resolve an optional explicit joined room without changing the active room.
218
+ * Explicit operations require an established identity and an existing
219
+ * membership, but a soft-left membership remains addressable: naming the room
220
+ * is deliberate and may be needed to inspect history or release old claims. */
221
+ function resolveJoinedRoom(room) {
222
+ if (room === undefined) {
223
+ const { agentId, roomId } = requireActive();
224
+ return {
225
+ agentId,
226
+ roomId,
227
+ roomName: store.getRoom(roomId)?.name ?? null,
228
+ };
229
+ }
230
+ if (session.agentId === null) {
231
+ throw new Error("join a room first with join_room to establish your identity");
232
+ }
233
+ const target = store.resolveRoom(room);
234
+ if (!target) {
235
+ throw new Error(`no room "${room}". Use list_rooms to see options.`);
236
+ }
237
+ if (!store.getMembership(target.id, session.agentId)) {
238
+ throw new Error(`you have never joined room "${target.name}"; join_room it first`);
239
+ }
240
+ return {
241
+ agentId: session.agentId,
242
+ roomId: target.id,
243
+ roomName: target.name,
244
+ };
245
+ }
246
+ /**
247
+ * Mark the active agent (and its private cursor, if any) alive on tool
248
+ * invocations. Throttled: every tool call otherwise costs a write transaction
249
+ * on the shared file (cross-process lock contention for pure reads like
250
+ * list_rooms). 30s granularity is far inside both consumers' tolerances: the
251
+ * `active` liveness window is minutes and the session GC is days.
252
+ */
253
+ let lastTouchMs = 0;
254
+ const TOUCH_INTERVAL_MS = 30_000;
255
+ function touchSession() {
256
+ if (session.agentId === null)
257
+ return;
258
+ const now = Date.now();
259
+ if (now - lastTouchMs < TOUCH_INTERVAL_MS)
260
+ return;
261
+ lastTouchMs = now;
262
+ // Always pass the nonce (not cursorId()): the ACTIVE room may be shared
263
+ // while this session holds private cursors in other rooms, and those rows
264
+ // must stay refreshed against the 7-day GC too.
265
+ try {
266
+ if (session.roomId !== null) {
267
+ store.touch(session.roomId, session.agentId, SESSION_NONCE);
268
+ }
269
+ else {
270
+ // Identity without an active room (post-leave my_mentions polling):
271
+ // still shield this session's cursors AND live presence rows from the GC.
272
+ store.touchSessionAlive(SESSION_NONCE, session.agentId);
273
+ }
274
+ }
275
+ catch {
276
+ // Liveness is best-effort: a briefly-locked database must not fail the
277
+ // tool call this touch piggybacks on (pure reads included). lastTouchMs
278
+ // already advanced, so failures back off to the next interval.
279
+ }
280
+ }
281
+ // Cross-room operations capture a target that can differ from mutable active
282
+ // session state. Throttle each captured (room, identity) independently: using
283
+ // touchSession() in a named-room wait kept refreshing the active room instead.
284
+ const capturedTouchMs = new Map();
285
+ function touchCapturedRoom(roomId, agentId) {
286
+ const key = privKey(roomId, agentId);
287
+ const now = Date.now();
288
+ if (now - (capturedTouchMs.get(key) ?? 0) < TOUCH_INTERVAL_MS)
289
+ return;
290
+ if (!capturedTouchMs.has(key) && capturedTouchMs.size >= 1024) {
291
+ for (const [candidate, touchedAt] of capturedTouchMs) {
292
+ if (now - touchedAt >= TOUCH_INTERVAL_MS)
293
+ capturedTouchMs.delete(candidate);
294
+ }
295
+ // A pathological stream of unique rooms/identities must not grow this
296
+ // process-lifetime throttle map without bound.
297
+ if (capturedTouchMs.size >= 1024) {
298
+ const oldest = capturedTouchMs.keys().next().value;
299
+ if (oldest !== undefined)
300
+ capturedTouchMs.delete(oldest);
301
+ }
302
+ }
303
+ capturedTouchMs.set(key, now);
304
+ try {
305
+ // Unlike store.touch(), this requires this exact session's presence row
306
+ // to remain live and therefore cannot resurrect an explicitly left room.
307
+ store.touchSessionRoom(roomId, agentId, SESSION_NONCE);
308
+ }
309
+ catch {
310
+ // Best-effort heartbeat, matching touchSession().
311
+ }
312
+ }
313
+ const BUILD = (() => {
314
+ try {
315
+ return JSON.parse(readFileSync(new URL("./build-info.json", import.meta.url), "utf8"));
316
+ }
317
+ catch {
318
+ return { version: "0.0.0-dev", commit: "unknown", built_at: "" };
319
+ }
320
+ })();
321
+ /**
322
+ * Re-read the on-disk build stamp and report whether a NEWER build has been
323
+ * deployed since this process started. If so, this server is stale: the client
324
+ * should reconnect the MCP to load the new code (a stdio server never
325
+ * hot-reloads). Modern stamps compare an executable-artifact hash so rebuilding
326
+ * identical code does not emit a false warning; timestamps remain the fallback
327
+ * for old stamps. No client UI surfaces serverInfo.version, so this in-band
328
+ * flag is the only way an agent learns it is running old code.
329
+ */
330
+ function buildStatus() {
331
+ let latest = null;
332
+ try {
333
+ latest = JSON.parse(readFileSync(new URL("./build-info.json", import.meta.url), "utf8"));
334
+ }
335
+ catch {
336
+ latest = null;
337
+ }
338
+ const runningHash = BUILD.artifact_hash ?? "";
339
+ const latestHash = latest?.artifact_hash ?? "";
340
+ const comparableHashes = runningHash.length > 0 && latestHash.length > 0;
341
+ const stale = comparableHashes
342
+ ? latestHash !== runningHash &&
343
+ latest !== null &&
344
+ latest.built_at > BUILD.built_at
345
+ : latest !== null &&
346
+ latest.built_at !== "" &&
347
+ latest.built_at > BUILD.built_at;
348
+ return {
349
+ stale,
350
+ latest_commit: latest?.commit ?? null,
351
+ latest_built_at: latest?.built_at ?? null,
352
+ latest_artifact_hash: latest?.artifact_hash ?? null,
353
+ };
354
+ }
355
+ const server = new McpServer({
356
+ name: "agent-chat-mcp",
357
+ version: BUILD.version,
358
+ }, { instructions: INSTRUCTIONS });
359
+ let toolStartTail = Promise.resolve();
360
+ const toolStartTickets = new WeakMap();
361
+ const activeToolRequests = new Set();
362
+ function issueToolStartTicket() {
363
+ const before = toolStartTail;
364
+ let resolveNext;
365
+ toolStartTail = new Promise((resolve) => {
366
+ resolveNext = resolve;
367
+ });
368
+ let released = false;
369
+ return {
370
+ before,
371
+ release() {
372
+ if (released)
373
+ return;
374
+ released = true;
375
+ resolveNext();
376
+ },
377
+ };
378
+ }
379
+ const sdkSetRequestHandler = server.server.setRequestHandler.bind(server.server);
380
+ server.server.setRequestHandler = ((requestSchema, handler) => {
381
+ if (requestSchema !== CallToolRequestSchema) {
382
+ Reflect.apply(sdkSetRequestHandler, server.server, [requestSchema, handler]);
383
+ return;
384
+ }
385
+ const sdkHandler = handler;
386
+ const orderedOuter = (request, extra) => {
387
+ const ticket = issueToolStartTicket();
388
+ toolStartTickets.set(extra.signal, ticket);
389
+ const pending = (async () => {
390
+ try {
391
+ return await sdkHandler(request, extra);
392
+ }
393
+ finally {
394
+ ticket.release();
395
+ toolStartTickets.delete(extra.signal);
396
+ }
397
+ })();
398
+ activeToolRequests.add(pending);
399
+ void pending.then(() => activeToolRequests.delete(pending), () => activeToolRequests.delete(pending));
400
+ return pending;
401
+ };
402
+ Reflect.apply(sdkSetRequestHandler, server.server, [
403
+ CallToolRequestSchema,
404
+ orderedOuter,
405
+ ]);
406
+ });
407
+ const sdkRegisterTool = server.registerTool.bind(server);
408
+ server.registerTool = ((name, config, callback) => {
409
+ if (typeof callback !== "function") {
410
+ return Reflect.apply(sdkRegisterTool, server, [name, config, callback]);
411
+ }
412
+ const orderedCallback = (...handlerArgs) => {
413
+ const extra = handlerArgs[handlerArgs.length - 1];
414
+ const ticket = toolStartTickets.get(extra.signal);
415
+ if (!ticket)
416
+ return Reflect.apply(callback, undefined, handlerArgs);
417
+ return (async () => {
418
+ await ticket.before;
419
+ if (extra.signal.aborted) {
420
+ ticket.release();
421
+ return fail("request cancelled before execution");
422
+ }
423
+ let result;
424
+ try {
425
+ // Every tool handler executes its state-binding prefix synchronously.
426
+ // catch_up's first await comes only after it captures identity/room.
427
+ result = Reflect.apply(callback, undefined, handlerArgs);
428
+ }
429
+ finally {
430
+ ticket.release();
431
+ }
432
+ return await result;
433
+ })();
434
+ };
435
+ return Reflect.apply(sdkRegisterTool, server, [name, config, orderedCallback]);
436
+ });
437
+ // Every inputSchema below is z.object(...).strict(): UNKNOWN keys are
438
+ // rejected, not silently stripped. Stripping turned typos into different
439
+ // operations -- mark_read({sequence:0}) marked the whole backlog read,
440
+ // post_message({too:[...]}) posted without its recipients. The SDK passes
441
+ // Zod schema instances through to its own validator, so strictness reaches
442
+ // the wire (and additionalProperties:false reaches the advertised schema).
443
+ // Size caps and byte budgets, published so they are discoverable BEFORE a
444
+ // failure instead of via a rejected call. Values mirror the zod schemas and
445
+ // store asserts; keep them in sync when either changes.
446
+ const LIMITS = {
447
+ message_body_max_bytes: MAX_MESSAGE_BODY_BYTES,
448
+ mcp_stdio_frame_max_bytes: MAX_MCP_FRAME_BYTES,
449
+ bulk_read_default_budget_chars: DEFAULT_MAX_BYTES,
450
+ max_bytes_range: [1000, 400_000],
451
+ get_message_max_chars_range: [100, 400_000],
452
+ wait_seconds_max: WAIT_CAP_SECONDS,
453
+ wait_seconds_default_max: DEFAULT_WAIT_CAP_SECONDS,
454
+ wait_seconds_configurable_hard_max: HARD_WAIT_CAP_SECONDS,
455
+ crossed_preview_chars_max: MAX_CROSSED_PREVIEW_CHARS,
456
+ client_message_id_max_chars: MAX_CLIENT_MESSAGE_ID_CHARS,
457
+ default_page_limits: {
458
+ catch_up: 50,
459
+ read_history: 50,
460
+ my_mentions: 50,
461
+ search_messages: 20,
462
+ listings: 200,
463
+ },
464
+ metadata_caps_chars: {
465
+ room_name: 200,
466
+ room_description: 2000,
467
+ pinned_intro: 10_000,
468
+ agent_id: 200,
469
+ agent_type: 100,
470
+ agent_role: 200,
471
+ agent_description: 2000,
472
+ claim_key: 500,
473
+ claim_note: 2000,
474
+ mentions_per_post: 100,
475
+ },
476
+ };
477
+ server.registerTool("server_info", {
478
+ title: "Server info, limits, and operating manual",
479
+ description: `Version ${BUILD.version}: Report this server's version/build identity, ` +
480
+ "shared size/response budgets (`limits`), and the full operating " +
481
+ "manual (`manual`: routing, paging, poller, multi-session cursors). " +
482
+ "Call this once when caps or exact semantics matter. `stale:true` = a " +
483
+ "newer build was deployed since this process started; reconnect the " +
484
+ "MCP to load it (stdio servers do not hot-reload).",
485
+ inputSchema: z.object({}).strict(),
486
+ }, async () => {
487
+ try {
488
+ touchSession();
489
+ const status = buildStatus();
490
+ return ok({
491
+ name: "agent-chat-mcp",
492
+ version: BUILD.version,
493
+ commit: BUILD.commit,
494
+ built_at: BUILD.built_at,
495
+ artifact_hash: BUILD.artifact_hash ?? null,
496
+ stale: status.stale,
497
+ latest_commit: status.latest_commit,
498
+ latest_built_at: status.latest_built_at,
499
+ latest_artifact_hash: status.latest_artifact_hash,
500
+ limits: LIMITS,
501
+ manual: MANUAL,
502
+ });
503
+ }
504
+ catch (e) {
505
+ return fail(asMessage(e));
506
+ }
507
+ });
508
+ server.registerTool("what_time_is_it_right_now", {
509
+ title: "Current time",
510
+ description: "Current time: `iso` (local ISO 8601 with zone offset), `unix` (UTC " +
511
+ "epoch SECONDS), `at` (local 'YYYY-MM-DD HH:MM:SS'), `timezone` (IANA " +
512
+ "name). `unix` matches each message's `unix`, so now.unix - " +
513
+ "message.unix = the message's age in seconds.",
514
+ inputSchema: z.object({}).strict(),
515
+ }, async () => {
516
+ try {
517
+ touchSession();
518
+ const t = store.currentTime();
519
+ return ok({
520
+ iso: t.iso,
521
+ unix: t.unix,
522
+ at: t.at,
523
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
524
+ });
525
+ }
526
+ catch (e) {
527
+ return fail(asMessage(e));
528
+ }
529
+ });
530
+ server.registerTool("create_room", {
531
+ title: "Create room",
532
+ description: "Create a chat room (rooms must exist before agents can join). Name it " +
533
+ "for the TOPIC (kebab-case, e.g. 'auth-refactor-review'), never for " +
534
+ "participants or generic labels: list_rooms names are how agents find " +
535
+ "rooms. `pinned` is an intro shown to every joiner. Returns the room id.",
536
+ inputSchema: z.object({
537
+ name: z
538
+ .string()
539
+ .min(1)
540
+ .max(200)
541
+ .describe("Unique room name; name it for the discussion topic (kebab-case), " +
542
+ "not for participants"),
543
+ description: z
544
+ .string()
545
+ .max(2000)
546
+ .optional()
547
+ .describe("What this room is for"),
548
+ pinned: z
549
+ .string()
550
+ .max(10_000)
551
+ .optional()
552
+ .describe("Pinned intro/conventions shown to joiners"),
553
+ }).strict(),
554
+ }, async ({ name, description, pinned }) => {
555
+ try {
556
+ touchSession();
557
+ // Room references are resolved id-first (resolveRoom), so an all-digit
558
+ // name would be shadowed by any room with that numeric id -- and
559
+ // delete_room resolves the same way, making the ambiguity destructive.
560
+ if (/^\d+$/.test(name)) {
561
+ return fail("room names cannot be all digits (ambiguous with room ids); " +
562
+ "pick a descriptive kebab-case topic name");
563
+ }
564
+ if (store.getRoomByName(name)) {
565
+ return fail(`a room named "${name}" already exists`);
566
+ }
567
+ const room = store.createRoom(name, description ?? null, pinned ?? null);
568
+ return ok({ room_id: room.id, name: room.name });
569
+ }
570
+ catch (e) {
571
+ // Two processes can pass the pre-check together; the loser's INSERT
572
+ // hits UNIQUE(rooms.name). Same outcome, friendlier message.
573
+ const msg = asMessage(e);
574
+ if (/UNIQUE constraint failed: rooms\.name/.test(msg)) {
575
+ return fail(`a room named "${name}" already exists`);
576
+ }
577
+ return fail(msg);
578
+ }
579
+ });
580
+ server.registerTool("list_rooms", {
581
+ title: "List rooms",
582
+ description: "List chat rooms (oldest first by id, up to `limit`; `total` reports how " +
583
+ "many exist) with present-member count, message count, last activity and " +
584
+ "pinned intro. Long pinned/descriptions are listing previews (*_truncated " +
585
+ "flags); join_room returns the full pinned. `next_id` present = more rows " +
586
+ "exist; page by passing it back as `after_id` (keyset paging, so a room " +
587
+ "deleted between pages cannot make you skip a live one).",
588
+ inputSchema: z
589
+ .object({
590
+ limit: z
591
+ .number()
592
+ .int()
593
+ .positive()
594
+ .max(1000)
595
+ .optional()
596
+ .describe("Max rooms to return (default 200)"),
597
+ after_id: z
598
+ .number()
599
+ .int()
600
+ .nonnegative()
601
+ .optional()
602
+ .describe("Keyset paging cursor: the prior page's next_id. Returns rooms " +
603
+ "whose id sorts after it."),
604
+ })
605
+ .strict(),
606
+ }, async ({ limit, after_id }) => {
607
+ try {
608
+ touchSession();
609
+ const { rooms, total, next_id, size_trimmed } = store.listRooms(limit ?? 200, after_id ?? 0);
610
+ return ok({
611
+ rooms,
612
+ total,
613
+ ...(next_id !== undefined ? { next_id, truncated: true } : {}),
614
+ ...(size_trimmed ? { size_trimmed: true } : {}),
615
+ });
616
+ }
617
+ catch (e) {
618
+ return fail(asMessage(e));
619
+ }
620
+ });
621
+ server.registerTool("join_room", {
622
+ title: "Join room",
623
+ description: "Join a room (id or name) under an identity; sets it active for the " +
624
+ "session. Omit agent_id to keep the session's current identity (on the " +
625
+ "FIRST join a generated readable id is assigned and returned; reuse it " +
626
+ "later to resume the same identity and read position). " +
627
+ "type/role/description tell other agents who you are. Read the returned " +
628
+ "`pinned` intro. `server_stale:true` = this server runs outdated code; " +
629
+ "tell the user to reconnect the MCP. `cursor` (for several sessions " +
630
+ "sharing one agent_id): 'shared' (default) = one marker per identity, " +
631
+ "concurrent sessions SPLIT the backlog (work-queue style); 'private' = " +
632
+ "this session keeps its own cursor (starting from the shared marker) " +
633
+ "and sees the full stream independently.",
634
+ inputSchema: z.object({
635
+ room: z.string().min(1).max(500).describe("Room id or name to join"),
636
+ agent_id: z
637
+ .string()
638
+ .max(200)
639
+ .refine((s) => !/[\u0000-\u001f\u007f]/.test(s), {
640
+ message: "control characters are not allowed in agent ids",
641
+ })
642
+ // Reject an empty/whitespace-only id when PROVIDED: the handler trims
643
+ // and treats a blank as "omitted" (keep/generate identity), so passing
644
+ // " " silently did something other than set that id. Omit the field
645
+ // to get that behaviour deliberately; a blank string is an error.
646
+ .refine((s) => s.trim().length > 0, {
647
+ message: "agent_id cannot be empty or whitespace-only; omit it to keep or auto-assign an identity",
648
+ })
649
+ .optional()
650
+ .describe("Your stable identity/nickname. Omit to keep the session identity " +
651
+ "(first join: a readable id is generated and returned)."),
652
+ type: z
653
+ .string()
654
+ .max(100)
655
+ .optional()
656
+ .describe("Agent type, e.g. 'claude', 'codex', 'gpt'"),
657
+ role: z
658
+ .string()
659
+ .max(200)
660
+ .optional()
661
+ .describe("Your role in the room, e.g. 'reviewer', 'planner'"),
662
+ description: z
663
+ .string()
664
+ .max(2000)
665
+ .optional()
666
+ .describe("Short description of who you are / what you do"),
667
+ cursor: z
668
+ .enum(["shared", "private"])
669
+ .optional()
670
+ .describe("'shared': one read marker per identity, concurrent sessions split " +
671
+ "the backlog. 'private': this session keeps its own read " +
672
+ "position. Omitted: keeps this session's current mode for the " +
673
+ "room (shared on first join); only an explicit 'shared' discards " +
674
+ "an existing private cursor."),
675
+ }).strict(),
676
+ }, async ({ room, agent_id, type, role, description, cursor }) => {
677
+ try {
678
+ touchSession();
679
+ const target = store.resolveRoom(room);
680
+ if (!target) {
681
+ return fail(`no room "${room}". Use list_rooms to see options or create_room to make one.`);
682
+ }
683
+ let id;
684
+ if (agent_id && agent_id.trim().length > 0) {
685
+ id = agent_id.trim();
686
+ store.upsertAgent(id, type ?? null, role ?? null, description ?? null);
687
+ }
688
+ else if (session.agentId !== null) {
689
+ // STICKY identity: a session that already established who it is
690
+ // keeps that identity on later joins. Generating a fresh id here
691
+ // forked the session into a second identity whose twin kept its own
692
+ // markers and memberships -- silent state the caller never asked for.
693
+ id = session.agentId;
694
+ store.upsertAgent(id, type ?? null, role ?? null, description ?? null);
695
+ }
696
+ else {
697
+ // Generated ids are claimed atomically inside assignReadableId, so no
698
+ // separate upsert here (it would risk clobbering a racing assigner).
699
+ id = assignReadableId(type ?? null, role ?? null, description ?? null);
700
+ }
701
+ // Session state mutates only AFTER the join succeeds: flipping cursor
702
+ // mode first would leave a failed join having silently changed the mode
703
+ // for the still-active previous room.
704
+ // Cursor mode is STICKY per (room, identity): an omitted `cursor` keeps
705
+ // this session's existing mode FOR THIS IDENTITY. Treating omission as
706
+ // an explicit 'shared' used to DELETE the session's private cursor on
707
+ // any rejoin, and a room-only key let ANOTHER identity's shared join
708
+ // clear this identity's mode with the same silent-loss outcome. Only an
709
+ // explicit 'shared' downgrades, and only for the identity that joins.
710
+ const key = privKey(target.id, id);
711
+ const priv = cursor === "private" ||
712
+ (cursor === undefined && session.privateRooms.has(key));
713
+ store.joinRoom(target.id, id, priv ? SESSION_NONCE : null, SESSION_NONCE);
714
+ if (priv) {
715
+ session.privateRooms.add(key);
716
+ }
717
+ else {
718
+ session.privateRooms.delete(key);
719
+ // Mode switch hygiene: drop any leftover private row so my_mentions'
720
+ // per-room COALESCE stops using a baseline this session abandoned.
721
+ // (Reached on explicit 'shared', or on omitted-cursor joins where the
722
+ // session held no private mode -- a no-op there.)
723
+ store.clearSessionCursor(target.id, id, SESSION_NONCE);
724
+ }
725
+ session.agentId = id;
726
+ session.roomId = target.id;
727
+ const cur = store.getCursor(target.id, id, cursorId());
728
+ if (!cur) {
729
+ // The room was deleted by another process between our join and this
730
+ // read; same recovery contract as requireActive.
731
+ session.roomId = null;
732
+ return fail(`room "${target.name}" was deleted while joining; rejoin with join_room`);
733
+ }
734
+ return ok({
735
+ agent_id: id,
736
+ room_id: target.id,
737
+ room_name: target.name,
738
+ description: target.description,
739
+ pinned: target.pinned,
740
+ cursor: priv ? "private" : "shared",
741
+ last_read_seq: cur.last_read_seq,
742
+ unread: store.unreadCount(target.id, cur.last_read_seq, id),
743
+ members: store.presentCount(target.id),
744
+ // Ready-to-run background poller invocation, shell-quoted for THIS
745
+ // id (see the server instructions for its options and semantics).
746
+ poller_cmd: pollerCmd(id, { session: SESSION_NONCE }),
747
+ // Surface staleness at the session-start checkpoint, where it is seen
748
+ // once without per-call noise. True => this server is running old code;
749
+ // reconnect the MCP. See server_info for the latest commit.
750
+ server_stale: buildStatus().stale,
751
+ });
752
+ }
753
+ catch (e) {
754
+ return fail(asMessage(e));
755
+ }
756
+ });
757
+ server.registerTool("leave_room", {
758
+ title: "Leave room",
759
+ description: "Soft-leave the active room: your read position is kept, rejoining " +
760
+ "resumes it. Clears the active room; your identity is kept (my_mentions " +
761
+ "and later joins still work).",
762
+ inputSchema: z.object({}).strict(),
763
+ }, async () => {
764
+ try {
765
+ touchSession();
766
+ const { agentId, roomId } = requireActive();
767
+ // Pass the process nonce so the leave is SESSION-scoped: it marks THIS
768
+ // session's presence row left and recomputes identity presence, so a live
769
+ // twin (shared or private) is never evicted. Presence is per-session for
770
+ // every mode now, independent of the cursor nonce.
771
+ const left = store.leaveRoom(roomId, agentId, SESSION_NONCE);
772
+ // Keep the identity: the session is still this agent, and my_mentions
773
+ // (memberships elsewhere) must keep working after leaving one room. The
774
+ // room's cursor-mode entry also stays: its private position is preserved
775
+ // for resume (matching leaveRoom keeping the session_markers row).
776
+ session.roomId = null;
777
+ return ok({ left, room_id: roomId, agent_id: agentId });
778
+ }
779
+ catch (e) {
780
+ return fail(asMessage(e));
781
+ }
782
+ });
783
+ server.registerTool("whoami", {
784
+ title: "Who am I",
785
+ description: "Report the current session identity, active room and unread count.",
786
+ inputSchema: z.object({}).strict(),
787
+ }, async () => {
788
+ try {
789
+ touchSession();
790
+ if (session.agentId === null || session.roomId === null) {
791
+ return ok({
792
+ joined: false,
793
+ ...(session.agentId !== null ? { agent_id: session.agentId } : {}),
794
+ });
795
+ }
796
+ const roomRow = store.getRoom(session.roomId);
797
+ if (!roomRow) {
798
+ // Room was deleted by another process; do not claim to be joined.
799
+ // The identity survives.
800
+ session.roomId = null;
801
+ return ok({
802
+ joined: false,
803
+ agent_id: session.agentId,
804
+ note: "active room was deleted; rejoin",
805
+ });
806
+ }
807
+ const cur = store.getCursor(session.roomId, session.agentId, cursorId());
808
+ return ok({
809
+ joined: true,
810
+ agent_id: session.agentId,
811
+ room_id: session.roomId,
812
+ room_name: roomRow?.name ?? null,
813
+ cursor: session.privateRooms.has(privKey(session.roomId, session.agentId))
814
+ ? "private"
815
+ : "shared",
816
+ last_read_seq: cur?.last_read_seq ?? 0,
817
+ unread: store.unreadCount(session.roomId, cur?.last_read_seq ?? 0, session.agentId),
818
+ });
819
+ }
820
+ catch (e) {
821
+ return fail(asMessage(e));
822
+ }
823
+ });
824
+ server.registerTool("list_agents", {
825
+ title: "List agents in room",
826
+ description: "List agents in the active room (up to `limit`; `total` rides along): " +
827
+ "type/role/description, `last_read_seq` (read receipt: compare to a " +
828
+ "message seq), `last_seen`, `idle_seconds`, `present` (has not left), " +
829
+ "`active` (present and recently seen or carrying an unexpired wait lease), " +
830
+ "`watching` (an unexpired best-effort blocking catch_up wait lease; " +
831
+ "not an acknowledgement or delivery guarantee). Long " +
832
+ "descriptions are listing previews (description_truncated). " +
833
+ "`next_after` present = more rows exist; page by passing it back as " +
834
+ "`after` (keyset paging, so a concurrent join cannot make you skip or " +
835
+ "duplicate an agent).",
836
+ inputSchema: z.object({
837
+ filter: z
838
+ .string()
839
+ .max(500)
840
+ .optional()
841
+ .describe("Substring to match against id/type/role/description"),
842
+ active_within_minutes: z
843
+ .number()
844
+ .positive()
845
+ .max(1440)
846
+ .optional()
847
+ .describe("Recent-seen window for `active`; an unexpired wait lease also " +
848
+ "makes a present agent active (default 5 minutes)"),
849
+ limit: z
850
+ .number()
851
+ .int()
852
+ .positive()
853
+ .max(1000)
854
+ .optional()
855
+ .describe("Max agents to return (default 200)"),
856
+ after: z
857
+ .number()
858
+ .int()
859
+ .positive()
860
+ .optional()
861
+ .describe("Keyset paging cursor: the prior page's next_after."),
862
+ }).strict(),
863
+ }, async ({ filter, active_within_minutes, limit, after }) => {
864
+ try {
865
+ touchSession();
866
+ const { roomId } = requireActive();
867
+ const { agents, total, next_after, size_trimmed } = store.listAgents(roomId, active_within_minutes ?? 5, filter, limit ?? 200, after);
868
+ return ok({
869
+ agents,
870
+ total,
871
+ ...(next_after !== undefined ? { next_after, truncated: true } : {}),
872
+ ...(size_trimmed ? { size_trimmed: true } : {}),
873
+ });
874
+ }
875
+ catch (e) {
876
+ return fail(asMessage(e));
877
+ }
878
+ });
879
+ server.registerTool("post_message", {
880
+ title: "Post message",
881
+ description: "Post text/JSON to the active or explicit joined `room`. Returns `seq`, " +
882
+ "factual recipient state, and `crossed` unread peer traffic for a new " +
883
+ "insert. `posted:true` means committed to SQLite only, not that a " +
884
+ "recipient was woken, acknowledged, or began processing it. A " +
885
+ "deduplicated retry returns the original seq/key only; catch " +
886
+ "up for current state. For a " +
887
+ "dispositive post, use `if_last_read_seq` + `expected_room`; use " +
888
+ "`client_message_id` to deduplicate an exact lost-response retry. " +
889
+ "`crossed_preview_chars` max is 2000. `priority:true` survives explicit " +
890
+ "priority-only backlog triage; `supersedes_seq` corrects your own post.",
891
+ inputSchema: z.object({
892
+ // ONE bare z.custom for all three shapes, deliberately:
893
+ // - NOT z.record / z.object().passthrough(): both rebuild the object by
894
+ // assignment, and assigning key "__proto__" sets the prototype rather
895
+ // than an own property, so a top-level "__proto__" key was silently
896
+ // dropped before storage. z.custom passes the raw parsed value through
897
+ // (JSON.parse already made "__proto__" a safe own key -- no prototype
898
+ // pollution), so it round-trips like any other key.
899
+ // - NOT a union with string/array arms: a z.custom arm is DROPPED from
900
+ // the generated JSON Schema, so tools/list advertised content as only
901
+ // string|array and schema-validating clients rejected every object
902
+ // body client-side. A bare z.custom generates an unconstrained schema,
903
+ // which admits objects; the description carries the contract and this
904
+ // validator enforces it at runtime.
905
+ content: z
906
+ .custom((v) => typeof v === "string" ||
907
+ Array.isArray(v) ||
908
+ (typeof v === "object" && v !== null), { message: "content must be a string, JSON object, or JSON array" })
909
+ .describe("Message body: a string, or a JSON object/array. Strings and " +
910
+ "object keys must be well-formed Unicode (no lone surrogates)."),
911
+ to: z
912
+ .array(z
913
+ .string()
914
+ .min(1)
915
+ .max(200)
916
+ .refine((s) => !/[\u0000-\u001f\u007f]/.test(s), {
917
+ message: "control characters are not allowed in agent ids",
918
+ }))
919
+ .max(100)
920
+ .optional()
921
+ .describe("agent_ids this message is directed at (mentions); max 100"),
922
+ reply_to_seq: z
923
+ .number()
924
+ .int()
925
+ .positive()
926
+ .optional()
927
+ .describe("seq of a message in this room you are replying to"),
928
+ supersedes_seq: z
929
+ .number()
930
+ .int()
931
+ .positive()
932
+ .optional()
933
+ .describe("seq of YOUR OWN earlier message that this message supersedes " +
934
+ "(correction/retraction)"),
935
+ priority: z
936
+ .boolean()
937
+ .optional()
938
+ .describe("Durable high-signal checkpoint for priority-only catch-up. " +
939
+ "Immutable; correct it with a new priority post + supersedes_seq."),
940
+ client_message_id: z
941
+ .string()
942
+ .min(1)
943
+ .max(MAX_CLIENT_MESSAGE_ID_CHARS)
944
+ .refine((s) => !/[\u0000-\u001f\u007f]/.test(s), {
945
+ message: "control characters are not allowed",
946
+ })
947
+ .optional()
948
+ .describe("Opaque idempotency key for this author+room. Repeating the exact " +
949
+ "stored payload returns the original seq; reusing it for a " +
950
+ "different payload fails. Repeat the same room/expected_room on " +
951
+ "retry. Retained only as long as the message."),
952
+ room: z
953
+ .string()
954
+ .min(1)
955
+ .max(500)
956
+ .optional()
957
+ .describe("Post to a room you have JOINED (id or name) without changing " +
958
+ "the active room. Omitted: the active room."),
959
+ expected_room: z
960
+ .string()
961
+ .min(1)
962
+ .max(500)
963
+ .optional()
964
+ .describe("Assert the ACTIVE room is this one (id or name); a mismatch " +
965
+ "rejects the post. For dispositive posts, so the implicit " +
966
+ "active room cannot silently misroute them. Not combinable " +
967
+ "with room."),
968
+ if_last_read_seq: z
969
+ .number()
970
+ .int()
971
+ .nonnegative()
972
+ .optional()
973
+ .describe("Conditional post (CAS) for dispositive messages: reject if ANY " +
974
+ "message from others carries a seq above this (use your last " +
975
+ "catch_up's new_last_read_seq). A token ahead of this room's " +
976
+ "effective read cursor is invalid and rejected before posting. " +
977
+ "A stale rejection returns posted:false with bounded crossed " +
978
+ "previews; call catch_up for the full delta, then re-send the " +
979
+ "same content with that call's token. Never needed for routine traffic."),
980
+ crossed_preview_chars: z
981
+ .number()
982
+ .int()
983
+ .positive()
984
+ .max(MAX_CROSSED_PREVIEW_CHARS)
985
+ .optional()
986
+ .describe(`Max ${MAX_CROSSED_PREVIEW_CHARS}. When crossed > 0, also return the crossed messages as bounded ` +
987
+ "previews (crossed_messages, per-row directed flag; " +
988
+ "crossed_remaining when the bound cut the list). Posting never " +
989
+ "consumes a crossed peer message: previews remain unread for " +
990
+ "catch_up. An accepted post may normalize the marker only across " +
991
+ "your own rows."),
992
+ }).strict(),
993
+ }, async ({ content, to, reply_to_seq, supersedes_seq, room, expected_room, if_last_read_seq, crossed_preview_chars, priority, client_message_id, }) => {
994
+ try {
995
+ touchSession();
996
+ if (room !== undefined && expected_room !== undefined) {
997
+ return fail("pass either room (explicit target) or expected_room (active-room " +
998
+ "assertion), not both");
999
+ }
1000
+ let agentId;
1001
+ let roomId;
1002
+ let roomName;
1003
+ let selector;
1004
+ if (room !== undefined) {
1005
+ // Explicit target: same membership rule as catch_up({room}).
1006
+ if (session.agentId === null) {
1007
+ return fail("join a room first with join_room to establish your identity");
1008
+ }
1009
+ agentId = session.agentId;
1010
+ const target = store.resolveRoom(room);
1011
+ if (!target) {
1012
+ return fail(`no room "${room}". Use list_rooms to see options.`);
1013
+ }
1014
+ if (!store.getMembership(target.id, agentId)) {
1015
+ return fail(`you have never joined room "${target.name}"; join_room it before posting there`);
1016
+ }
1017
+ roomId = target.id;
1018
+ roomName = target.name;
1019
+ selector = session.privateRooms.has(privKey(target.id, agentId))
1020
+ ? SESSION_NONCE
1021
+ : null;
1022
+ }
1023
+ else {
1024
+ ({ agentId, roomId } = requireActive());
1025
+ roomName = store.getRoom(roomId)?.name ?? null;
1026
+ selector = cursorId();
1027
+ if (expected_room !== undefined) {
1028
+ const expect = store.resolveRoom(expected_room);
1029
+ if (!expect || expect.id !== roomId) {
1030
+ return fail(`expected_room "${expected_room}" does not match the active room ` +
1031
+ `(${roomId}${roomName ? ` "${roomName}"` : ""}); nothing was posted. ` +
1032
+ "Pass room: to target a specific room, or join_room it first.");
1033
+ }
1034
+ }
1035
+ }
1036
+ touchCapturedRoom(roomId, agentId);
1037
+ const isText = typeof content === "string";
1038
+ // Validate structured strings DURING serialization, before JSON.stringify
1039
+ // escapes lone surrogates to harmless-looking ASCII. The store validates
1040
+ // the serialized body too, but cannot recover this semantic distinction.
1041
+ const body = isText
1042
+ ? content
1043
+ : stringifyWellFormedJson(content, "message content");
1044
+ if (Buffer.byteLength(body, "utf8") > MAX_MESSAGE_BODY_BYTES) {
1045
+ return fail(`message body exceeds the ${MAX_MESSAGE_BODY_BYTES}-byte safety limit`);
1046
+ }
1047
+ const mentions = to && to.length > 0 ? dedupe(to) : null;
1048
+ const res = store.postMessage(roomId, agentId, body, isText ? "text" : "json", mentions, reply_to_seq ?? null, supersedes_seq ?? null, selector, {
1049
+ ifLastReadSeq: if_last_read_seq ?? null,
1050
+ crossedPreviewChars: crossed_preview_chars,
1051
+ recipientActiveWithinMinutes: mentions ? 5 : undefined,
1052
+ priority: priority === true,
1053
+ clientMessageId: client_message_id ?? null,
1054
+ });
1055
+ if (!res.posted) {
1056
+ if (res.rejected === "evidence_pruned") {
1057
+ return ok({
1058
+ posted: false,
1059
+ rejected: "evidence_pruned",
1060
+ room_id: roomId,
1061
+ room_name: roomName,
1062
+ oldest_retained_seq: res.oldest_retained_seq,
1063
+ pruned_through_seq: res.pruned_through_seq,
1064
+ retry: "messages after that token were pruned, so the server cannot prove the post is still current. " +
1065
+ "Call catch_up for this room, then re-send the SAME content with if_last_read_seq set to that " +
1066
+ "call's new_last_read_seq (nothing was stored)",
1067
+ });
1068
+ }
1069
+ // CAS reject: a structured non-error result (like claim's
1070
+ // granted:false). Nothing was stored; the caller's payload is its
1071
+ // own to re-send, so the reject carries the delta, not a draft.
1072
+ return ok({
1073
+ posted: false,
1074
+ rejected: "stale_read",
1075
+ room_id: roomId,
1076
+ room_name: roomName,
1077
+ crossed: res.crossed,
1078
+ crossed_directed: res.crossed_directed,
1079
+ crossed_range: res.crossed_range,
1080
+ crossed_messages: res.crossed_messages,
1081
+ ...(res.crossed_remaining !== undefined
1082
+ ? { crossed_remaining: res.crossed_remaining }
1083
+ : {}),
1084
+ retry: "call catch_up for this room, review the complete delta, then " +
1085
+ "re-send the SAME content with if_last_read_seq set to that " +
1086
+ "call's new_last_read_seq (nothing was stored)",
1087
+ });
1088
+ }
1089
+ if (res.deduplicated) {
1090
+ return ok({
1091
+ posted: true,
1092
+ deduplicated: true,
1093
+ seq: res.seq,
1094
+ room_id: roomId,
1095
+ room_name: roomName,
1096
+ client_message_id: res.client_message_id,
1097
+ note: "the original post was already stored; no second row was inserted. " +
1098
+ "The original crossed/recipient snapshot is not replayed; call catch_up for current state",
1099
+ });
1100
+ }
1101
+ const { seq, crossed, crossed_directed, crossed_range } = res;
1102
+ const recipients = res.recipients ?? [];
1103
+ // Loud but factual delivery state. Unknown/left are definitive routing
1104
+ // facts. Room-local idleness is not a responsiveness prediction, so it
1105
+ // is mentioned only when older backlog already existed; seq-1 is the
1106
+ // pre-insert room maximum and costs no extra query.
1107
+ const delivery_warnings = recipients.flatMap((r) => {
1108
+ if (r.status === "unknown") {
1109
+ return [`${r.id}: never joined this room; the tag reaches no one`];
1110
+ }
1111
+ if (r.status === "left") {
1112
+ return [
1113
+ r.watching
1114
+ ? `${r.id}: left this room; a wait lease is still recorded but may be stale`
1115
+ : `${r.id}: left this room; the message waits unread unless they return`,
1116
+ ];
1117
+ }
1118
+ if (r.watching)
1119
+ return [];
1120
+ const priorMarkerBehind = r.last_read_seq === null ? 0 : Math.max(0, seq - 1 - r.last_read_seq);
1121
+ if (r.status === "idle" &&
1122
+ (r.idle_seconds ?? 0) >= DELIVERY_STALL_SECONDS &&
1123
+ priorMarkerBehind > 0) {
1124
+ return [
1125
+ `${r.id}: no observed activity in this room for ${fmtIdle(r.idle_seconds ?? 0)}; ` +
1126
+ `marker was ${priorMarkerBehind} seq behind before this post`,
1127
+ ];
1128
+ }
1129
+ return [];
1130
+ });
1131
+ return ok({
1132
+ posted: true,
1133
+ seq,
1134
+ room_id: roomId,
1135
+ room_name: roomName,
1136
+ ...(delivery_warnings.length > 0 ? { delivery_warnings } : {}),
1137
+ format: isText ? "text" : "json",
1138
+ priority: res.priority,
1139
+ ...(res.client_message_id !== undefined
1140
+ ? { client_message_id: res.client_message_id }
1141
+ : {}),
1142
+ to: mentions,
1143
+ reply_to_seq: reply_to_seq ?? null,
1144
+ supersedes_seq: supersedes_seq ?? null,
1145
+ crossed,
1146
+ crossed_directed,
1147
+ crossed_range,
1148
+ ...(res.crossed_messages !== undefined
1149
+ ? { crossed_messages: res.crossed_messages }
1150
+ : {}),
1151
+ ...(res.crossed_remaining !== undefined
1152
+ ? { crossed_remaining: res.crossed_remaining }
1153
+ : {}),
1154
+ recipients,
1155
+ });
1156
+ }
1157
+ catch (e) {
1158
+ return fail(asMessage(e));
1159
+ }
1160
+ });
1161
+ server.registerTool("catch_up", {
1162
+ title: "Catch up on new messages",
1163
+ description: "Read one active/explicit joined room and ADVANCE its marker. Default is " +
1164
+ "lossless; `priority_only:true` is explicit lossy triage that always " +
1165
+ `keeps directed rows. \`wait_seconds\` blocks this call (effective max ${WAIT_CAP_SECONDS}; ` +
1166
+ "host/client limits may be lower). Empty reads disclose other unread " +
1167
+ "rooms. Own posts are skipped; use my_mentions for a cross-room peek.",
1168
+ inputSchema: z.object({
1169
+ room: z
1170
+ .string()
1171
+ .min(1)
1172
+ .max(500)
1173
+ .optional()
1174
+ .describe("Read a room you have JOINED (id or name) without changing the " +
1175
+ "active room; its read marker still advances. Omitted: the " +
1176
+ "active room."),
1177
+ wait_seconds: z
1178
+ .number()
1179
+ .int()
1180
+ .min(0)
1181
+ .max(WAIT_CAP_SECONDS)
1182
+ .optional()
1183
+ .describe(`Effective max ${WAIT_CAP_SECONDS}. Block until a message from another agent ` +
1184
+ "lands in the target room, then return it (marker advances) in " +
1185
+ "this same call; 0/omitted = return immediately. On timeout: " +
1186
+ "timed_out:true + call_again. Default max: 25. Operators may set " +
1187
+ "AGENT_CHAT_MAX_WAIT_SECONDS up to 120 only after measuring the " +
1188
+ "host timeout. Waiting holds YOUR turn; use the poller while doing other work."),
1189
+ priority_only: z
1190
+ .boolean()
1191
+ .optional()
1192
+ .describe("LOSSY backlog triage: return priority:true messages plus every " +
1193
+ "mention/reply directed at you, and advance past lower-priority " +
1194
+ "rows through cutoff_seq. Cannot be combined with wait_seconds."),
1195
+ limit: z
1196
+ .number()
1197
+ .int()
1198
+ .positive()
1199
+ .max(500)
1200
+ .optional()
1201
+ .describe("Max messages to return this call (default 50)"),
1202
+ preview_chars: z
1203
+ .number()
1204
+ .int()
1205
+ .positive()
1206
+ .optional()
1207
+ .describe("Truncate each body to this many chars; cut bodies carry " +
1208
+ "truncated:true + length (for an explicit cross-room read, fetch " +
1209
+ "the full body with get_message using the same room). Truncated " +
1210
+ "json = partial string, not an object."),
1211
+ max_bytes: z
1212
+ .number()
1213
+ .int()
1214
+ .min(1000)
1215
+ .max(400_000)
1216
+ .optional()
1217
+ .describe("Serialized-size budget for the complete response (default 100000). " +
1218
+ "Normal mode advances only over returned peer messages and any " +
1219
+ "following own-only suffix; priority-only " +
1220
+ "mode may also advance over disclosed skipped_count rows. " +
1221
+ "byte_limited:true = more remain, call again. An unusually " +
1222
+ "escape-heavy room name may require a larger value so the fixed " +
1223
+ "routing metadata plus one recoverable message stub can fit."),
1224
+ mentions_me: z
1225
+ .boolean()
1226
+ .optional()
1227
+ .describe("REMOVED in v0.6.0; use my_mentions. Passing it is an error."),
1228
+ after_seq: z
1229
+ .number()
1230
+ .optional()
1231
+ .describe("REMOVED in v0.6.0; my_mentions pages with after_id."),
1232
+ }).strict(),
1233
+ }, async ({ room, wait_seconds, priority_only, limit, preview_chars, max_bytes, mentions_me, after_seq, }, extra) => {
1234
+ const startedMs = Date.now();
1235
+ let heldWaitSlot = false;
1236
+ try {
1237
+ touchSession();
1238
+ // Reject, never strip: a v0.5 caller sending mentions_me expected a
1239
+ // non-advancing filtered peek; silently running an ADVANCING full sync
1240
+ // instead would eat its unread backlog.
1241
+ if (mentions_me !== undefined || after_seq !== undefined) {
1242
+ return fail("mentions_me/after_seq were removed in v0.6.0: catch_up is now " +
1243
+ "a full room sync by default and ADVANCES your marker. For messages " +
1244
+ "directed at you use my_mentions (cross-room inbox, never advances " +
1245
+ "markers, pages with after_id). This call was rejected instead of " +
1246
+ "silently changing semantics.");
1247
+ }
1248
+ if (priority_only === true && (wait_seconds ?? 0) > 0) {
1249
+ return fail("priority_only is lossy backlog triage and cannot be combined with " +
1250
+ "wait_seconds; run priority_only once, then use ordinary " +
1251
+ "catch_up({wait_seconds}) for live traffic");
1252
+ }
1253
+ const waitSeconds = wait_seconds ?? 0;
1254
+ // Acquire the bounded-wait slot before room resolution or liveness
1255
+ // writes. Otherwise a burst of rejected waits aimed at distinct rooms
1256
+ // could still perform an unbounded burst of synchronous DB work.
1257
+ if (waitSeconds > 0) {
1258
+ if (activeBlockingWaits >= MAX_CONCURRENT_WAITS) {
1259
+ return fail(`at most ${MAX_CONCURRENT_WAITS} blocking catch_up waits may run ` +
1260
+ "in one MCP process; wait for one to finish or use the background watcher");
1261
+ }
1262
+ activeBlockingWaits++;
1263
+ heldWaitSlot = true;
1264
+ }
1265
+ // --- Resolve and CAPTURE, all before the first await. Concurrent
1266
+ // dispatch can mutate `session` (identity, active room, cursor modes)
1267
+ // while a wait sleeps, so everything below runs off these captured
1268
+ // values; the only deliberate re-read of session state is the
1269
+ // cursor-mode epoch check (modeFlipped).
1270
+ let agentId;
1271
+ let roomId;
1272
+ let roomName;
1273
+ let selector;
1274
+ if (room !== undefined) {
1275
+ // Cross-room read: the ACTIVE room and its cursor mode stay untouched.
1276
+ // Requires an existing membership (a never-joined room has no read
1277
+ // position to advance); a soft-left room stays readable -- naming it
1278
+ // is the intent to read it (parity with the scoped poller watch).
1279
+ if (session.agentId === null) {
1280
+ return fail("join a room first with join_room to establish your identity");
1281
+ }
1282
+ agentId = session.agentId;
1283
+ const target = store.resolveRoom(room);
1284
+ if (!target) {
1285
+ return fail(`no room "${room}". Use list_rooms to see options.`);
1286
+ }
1287
+ if (!store.getMembership(target.id, agentId)) {
1288
+ return fail(`you have never joined room "${target.name}", so there is no read position to advance; join_room it first`);
1289
+ }
1290
+ roomId = target.id;
1291
+ roomName = target.name;
1292
+ // Cursor selector for the TARGET room. cursorId() answers only for
1293
+ // the active room, so it must not be used here.
1294
+ selector = session.privateRooms.has(privKey(target.id, agentId))
1295
+ ? SESSION_NONCE
1296
+ : null;
1297
+ }
1298
+ else {
1299
+ ({ agentId, roomId } = requireActive());
1300
+ roomName = store.getRoom(roomId)?.name ?? null;
1301
+ selector = cursorId();
1302
+ }
1303
+ touchCapturedRoom(roomId, agentId);
1304
+ const signal = extra?.signal;
1305
+ // max_bytes bounds the COMPLETE JSON text returned to the MCP client,
1306
+ // not merely ChatStore.catchUp's inner object. v0.9 added routing fields
1307
+ // and v0.10 added wait fields after the store had spent the full budget;
1308
+ // an advancing page could then be rejected after its marker committed.
1309
+ // Reserve their exact serialized cost BEFORE the advancing transaction.
1310
+ const responseIdentity = {
1311
+ agent_id: agentId,
1312
+ room_id: roomId,
1313
+ room_name: roomName,
1314
+ };
1315
+ const responseMetadataReserve = JSON.stringify({
1316
+ ...responseIdentity,
1317
+ ...(waitSeconds > 0
1318
+ ? {
1319
+ waited_ms: Number.MAX_SAFE_INTEGER,
1320
+ timed_out: true,
1321
+ call_again: true,
1322
+ }
1323
+ : {}),
1324
+ }).length - 1; // merging two non-empty objects replaces `}{` with `,`
1325
+ const requestedMaxBytes = max_bytes ?? DEFAULT_MAX_BYTES;
1326
+ const storeMaxBytes = requestedMaxBytes - responseMetadataReserve;
1327
+ if (storeMaxBytes < MIN_CATCH_UP_RESULT_BUDGET) {
1328
+ return fail(`max_bytes=${requestedMaxBytes} is too small for this room/identity's ` +
1329
+ `serialized catch_up metadata; use at least ${responseMetadataReserve + MIN_CATCH_UP_RESULT_BUDGET} (nothing was read or advanced)`);
1330
+ }
1331
+ // Identity fields ride on EVERY response so the caller always knows
1332
+ // which room (under which identity) this call consumed.
1333
+ const respond = (result, extraFields = {}) => ok({
1334
+ ...responseIdentity,
1335
+ ...result,
1336
+ ...extraFields,
1337
+ });
1338
+ const advancingRead = (includeUnreadSummary) => store.catchUp(roomId, agentId, limit ?? 50, preview_chars, storeMaxBytes, selector,
1339
+ // rooms_with_unread on an empty read; the RAW nonce, my_mentions
1340
+ // style, so every room baselines off its own cursor mode.
1341
+ includeUnreadSummary
1342
+ ? {
1343
+ sessionId: SESSION_NONCE,
1344
+ priorityOnly: priority_only === true,
1345
+ }
1346
+ : null);
1347
+ // Cursor-mode epoch check: a private<->shared rejoin mid-wait re-bases
1348
+ // the cursor (an explicit shared rejoin even deletes the private row),
1349
+ // so an advancing read after a flip would consume from a DIFFERENT
1350
+ // position than this call captured. Abort loudly instead.
1351
+ const modeFlipped = () => (session.privateRooms.has(privKey(roomId, agentId))
1352
+ ? SESSION_NONCE
1353
+ : null) !== selector;
1354
+ const sessionChangedResult = () => respond({
1355
+ messages: [],
1356
+ session_changed: true,
1357
+ call_again: true,
1358
+ waited_ms: Date.now() - startedMs,
1359
+ note: "this room's cursor mode flipped (private/shared rejoin) " +
1360
+ "mid-wait; nothing was read or advanced -- call catch_up again " +
1361
+ "to read from the current cursor",
1362
+ });
1363
+ const roomDeletedResult = (duringWait = false) => {
1364
+ // A delete racing the active-room read invalidates that active route.
1365
+ // Do not clear a different room selected by a concurrent join, nor the
1366
+ // active room during an explicit cross-room catch_up.
1367
+ if (session.roomId === roomId && session.agentId === agentId) {
1368
+ session.roomId = null;
1369
+ }
1370
+ return fail(`room "${roomName ?? roomId}" was deleted while ${duringWait ? "waiting" : "reading"}; nothing was read. list_rooms shows what still exists.`);
1371
+ };
1372
+ // Abort boundary rule for everything below: once an abort has been
1373
+ // observed, NO advancing transaction may run.
1374
+ if (signal?.aborted)
1375
+ return respond({ aborted: true });
1376
+ // A blocking wait discards an initial empty result. Do not compute its
1377
+ // exact cross-room unread summary only to throw it away; the timeout read
1378
+ // below includes the summary that is actually returned.
1379
+ let first;
1380
+ try {
1381
+ first = advancingRead(waitSeconds === 0);
1382
+ }
1383
+ catch (e) {
1384
+ if (!store.getRoom(roomId))
1385
+ return roomDeletedResult();
1386
+ throw e;
1387
+ }
1388
+ if (first.messages.length > 0 || waitSeconds === 0) {
1389
+ return respond(first, waitSeconds > 0 ? { waited_ms: Date.now() - startedMs } : {});
1390
+ }
1391
+ // --- Blocking wait: abort-aware timer, non-advancing read-only probe
1392
+ // with catchUp's exact predicate, advancing read only on a hit.
1393
+ const deadlineMs = startedMs + waitSeconds * 1000;
1394
+ // One lease token per CALL, not per process. Concurrent waits from one
1395
+ // MCP process must not overwrite and then delete each other's row.
1396
+ const waitLeaseId = `${SESSION_NONCE}:${randomUUID()}`;
1397
+ // Presence lease: when its best-effort write succeeds, `watching`
1398
+ // records that this call was open. TTL bounds a hard-kill ghost; a lease
1399
+ // failure must not break the wait (the probe surfaces a deleted room).
1400
+ try {
1401
+ store.beginWaitLease(roomId, agentId, waitLeaseId, waitSeconds + WAIT_LEASE_GRACE_SECONDS);
1402
+ }
1403
+ catch { }
1404
+ try {
1405
+ while (Date.now() < deadlineMs) {
1406
+ await abortableSleep(Math.min(WAIT_PROBE_INTERVAL_MS, deadlineMs - Date.now()), signal);
1407
+ if (signal?.aborted)
1408
+ return respond({ aborted: true });
1409
+ // The final advancing read below is the deadline check. Do not start
1410
+ // a heartbeat/probe after the requested wait has elapsed: either can
1411
+ // consume SQLite's busy timeout and needlessly extend total RPC time.
1412
+ if (Date.now() >= deadlineMs)
1413
+ break;
1414
+ // Self-throttled heartbeat: a genuinely-waiting agent reads as
1415
+ // `active` to peers instead of indistinguishable from a dormant one.
1416
+ touchCapturedRoom(roomId, agentId);
1417
+ let unread;
1418
+ try {
1419
+ unread = store.unreadProbe(roomId, agentId, selector);
1420
+ }
1421
+ catch (e) {
1422
+ if (!store.getRoom(roomId))
1423
+ return roomDeletedResult(true);
1424
+ throw e;
1425
+ }
1426
+ if (unread === 0)
1427
+ continue;
1428
+ if (signal?.aborted)
1429
+ return respond({ aborted: true });
1430
+ if (modeFlipped())
1431
+ return sessionChangedResult();
1432
+ let hit;
1433
+ try {
1434
+ // A probe/read race may make this empty too; its result is discarded,
1435
+ // so omit the cross-room exact-count summary here as well.
1436
+ hit = advancingRead(false);
1437
+ }
1438
+ catch (e) {
1439
+ if (!store.getRoom(roomId))
1440
+ return roomDeletedResult(true);
1441
+ throw e;
1442
+ }
1443
+ // Cursor normalization may advance across an own-only suffix while
1444
+ // returning no messages. That is maintenance, not a wake result.
1445
+ if (hit.messages.length > 0) {
1446
+ return respond(hit, { waited_ms: Date.now() - startedMs });
1447
+ }
1448
+ // A shared twin consumed the page between probe and read: keep
1449
+ // waiting on the (now advanced) cursor, never refire stale rows.
1450
+ }
1451
+ if (signal?.aborted)
1452
+ return respond({ aborted: true });
1453
+ if (modeFlipped())
1454
+ return sessionChangedResult();
1455
+ let last;
1456
+ try {
1457
+ last = advancingRead(true);
1458
+ }
1459
+ catch (e) {
1460
+ if (!store.getRoom(roomId))
1461
+ return roomDeletedResult(true);
1462
+ throw e;
1463
+ }
1464
+ return respond(last, {
1465
+ waited_ms: Date.now() - startedMs,
1466
+ ...(last.messages.length === 0
1467
+ ? { timed_out: true, call_again: true }
1468
+ : {}),
1469
+ });
1470
+ }
1471
+ finally {
1472
+ try {
1473
+ store.endWaitLease(roomId, agentId, waitLeaseId);
1474
+ }
1475
+ catch { }
1476
+ }
1477
+ }
1478
+ catch (e) {
1479
+ return fail(asMessage(e));
1480
+ }
1481
+ finally {
1482
+ if (heldWaitSlot)
1483
+ activeBlockingWaits--;
1484
+ }
1485
+ });
1486
+ server.registerTool("my_mentions", {
1487
+ title: "My mentions inbox (all rooms)",
1488
+ description: "Cross-room INBOX: unread messages directed at you (your @mentions, or " +
1489
+ "replies to messages you wrote) across EVERY room you are present in, " +
1490
+ "oldest first, tagged room_id/room_name; rooms you left are muted. " +
1491
+ "Strictly a PEEK: never advances read markers; an entry clears once you " +
1492
+ "read its room (catch_up or mark_read there); page more with after_id = " +
1493
+ "next_after_id. Needs an identity, not an active room. `by_room` lists " +
1494
+ "every room with ANY unread from others: `directed` (aimed at you) and " +
1495
+ "`unread` (total, broadcasts included); an EMPTY inbox with nonzero " +
1496
+ "by_room unread means rooms still have traffic to sync, not silence.",
1497
+ inputSchema: z.object({
1498
+ limit: z
1499
+ .number()
1500
+ .int()
1501
+ .positive()
1502
+ .max(500)
1503
+ .optional()
1504
+ .describe("Max inbox entries to return (default 50)"),
1505
+ preview_chars: z
1506
+ .number()
1507
+ .int()
1508
+ .positive()
1509
+ .optional()
1510
+ .describe("Truncate each body to this many chars (truncated:true + length " +
1511
+ "mark the cut; fetch the full body with get_message and pass the " +
1512
+ "entry's room_id or room_name as room)"),
1513
+ max_bytes: z
1514
+ .number()
1515
+ .int()
1516
+ .min(1000)
1517
+ .max(400_000)
1518
+ .optional()
1519
+ .describe("Serialized-size budget for the response (default 100000)"),
1520
+ after_id: z
1521
+ .number()
1522
+ .int()
1523
+ .nonnegative()
1524
+ .optional()
1525
+ .describe("Paging cursor: the prior response's next_after_id. Paging state " +
1526
+ "only; moves no read marker."),
1527
+ }).strict(),
1528
+ }, async ({ limit, preview_chars, max_bytes, after_id }) => {
1529
+ try {
1530
+ touchSession();
1531
+ if (session.agentId === null) {
1532
+ return fail("join a room first with join_room to establish your identity");
1533
+ }
1534
+ // Always pass the nonce: the store's per-room COALESCE uses this
1535
+ // session's private cursor exactly where one exists (shared joins clear
1536
+ // theirs), so each room gets ITS OWN mode rather than the active room's.
1537
+ return ok(store.myMentions(session.agentId, limit ?? 50, preview_chars, max_bytes, SESSION_NONCE, after_id ?? 0));
1538
+ }
1539
+ catch (e) {
1540
+ return fail(asMessage(e));
1541
+ }
1542
+ });
1543
+ server.registerTool("pending_work", {
1544
+ title: "Pending directed work (all agents)",
1545
+ description: "Cross-agent view: which PRESENT agents have unread messages directed " +
1546
+ "at them (mentions or replies), one row per agent+room, oldest pending " +
1547
+ "first with `oldest_seq`/`oldest_unix` and per-room `idle_seconds`. " +
1548
+ "For a supervisor deciding whom to wake or nudge; my_mentions answers " +
1549
+ "this only for yourself. Read markers are identity-level, so a lagging " +
1550
+ "private session can be further behind than shown. `next_after` means " +
1551
+ "more rows exist; pass it back as `after`. Pending rows are live, so " +
1552
+ "dedupe agent_id+room_id during a paged sweep.",
1553
+ inputSchema: z.object({
1554
+ limit: z
1555
+ .number()
1556
+ .int()
1557
+ .positive()
1558
+ .max(500)
1559
+ .optional()
1560
+ .describe("Max rows to return (default 50)"),
1561
+ after: z
1562
+ .object({
1563
+ oldest_unix: z.number().int().nonnegative(),
1564
+ agent_id: z
1565
+ .string()
1566
+ .min(1)
1567
+ .max(200)
1568
+ .refine((s) => !/[\u0000-\u001f\u007f]/.test(s), {
1569
+ message: "control characters are not allowed in agent ids",
1570
+ }),
1571
+ room_id: z.number().int().positive(),
1572
+ })
1573
+ .strict()
1574
+ .optional()
1575
+ .describe("Keyset cursor returned as next_after by the prior page"),
1576
+ }).strict(),
1577
+ }, async ({ limit, after }) => {
1578
+ try {
1579
+ touchSession();
1580
+ const { pending, truncated, size_trimmed, next_after } = store.pendingDirected(limit ?? 50, after);
1581
+ return ok({
1582
+ pending,
1583
+ ...(truncated ? { truncated: true, next_after } : {}),
1584
+ ...(size_trimmed ? { size_trimmed: true } : {}),
1585
+ });
1586
+ }
1587
+ catch (e) {
1588
+ return fail(asMessage(e));
1589
+ }
1590
+ });
1591
+ server.registerTool("wait_for_messages", {
1592
+ title: "Wait for new messages (background poller)",
1593
+ description: "Return (do not run) one childless background poller command. It watches " +
1594
+ "all joined rooms or one `room`; `mentions_only` narrows it. Generated " +
1595
+ "commands exit 0 on hit or quiet deadline: parse stdout `has_updates`. " +
1596
+ "Use catch_up({wait_seconds}) for an in-turn blocking read.",
1597
+ inputSchema: z
1598
+ .object({
1599
+ room: z
1600
+ .string()
1601
+ .min(1)
1602
+ .max(500)
1603
+ .optional()
1604
+ .describe("Scope the watch to one room (id or name); default watches every " +
1605
+ "room you are present in"),
1606
+ mentions_only: z
1607
+ .boolean()
1608
+ .optional()
1609
+ .describe("Fire only when a message mentions you or replies to you"),
1610
+ timeout: z
1611
+ .number()
1612
+ .int()
1613
+ .min(1)
1614
+ .max(86_400)
1615
+ .optional()
1616
+ .describe("Absolute finite deadline (default 1200). Generated commands " +
1617
+ "report a quiet deadline as has_updates:false with exit 0; " +
1618
+ "direct CLI without --ok-on-timeout uses exit 124."),
1619
+ interval: z
1620
+ .number()
1621
+ .int()
1622
+ .min(5)
1623
+ .max(3600)
1624
+ .optional()
1625
+ .describe("Seconds between probes (default 5)"),
1626
+ })
1627
+ .strict(),
1628
+ }, async ({ room, mentions_only, timeout, interval }) => {
1629
+ try {
1630
+ touchSession();
1631
+ const agentId = session.agentId;
1632
+ if (agentId === null) {
1633
+ return fail("join a room first with join_room to establish your identity, then call this again");
1634
+ }
1635
+ let roomArg;
1636
+ if (room !== undefined) {
1637
+ // Resolve to an id and require only that a membership row EXISTS -- not
1638
+ // that it is still present. A scoped --room probe (check.ts) baselines
1639
+ // off the room's preserved read marker regardless of left_at, and the
1640
+ // poller contract deliberately supports watching a room after
1641
+ // soft-leaving it ("naming the room is the intent to watch it").
1642
+ // Rejecting soft-left here contradicted that and blocked a valid watch.
1643
+ // A never-joined room has no marker to baseline from, so that still
1644
+ // fails with the real remedy.
1645
+ const target = store.resolveRoom(room);
1646
+ if (!target) {
1647
+ return fail(`no room "${room}". Use list_rooms to see options, or omit room to watch all rooms you are in.`);
1648
+ }
1649
+ const m = store.getMembership(target.id, agentId);
1650
+ if (!m) {
1651
+ return fail(`you have never joined room "${target.name}", so there is no read position to watch from; join_room it first, then call wait_for_messages`);
1652
+ }
1653
+ roomArg = String(target.id);
1654
+ }
1655
+ else if (store.presentRoomCount(agentId) === 0) {
1656
+ // Unscoped watch of ALL your rooms, but you are in none: the poller
1657
+ // would exit 2 immediately. Say so rather than emit a doomed command.
1658
+ 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");
1659
+ }
1660
+ const command = pollerCmd(agentId, {
1661
+ room: roomArg,
1662
+ mentionsOnly: mentions_only,
1663
+ session: SESSION_NONCE,
1664
+ timeoutSec: timeout,
1665
+ intervalSec: interval,
1666
+ });
1667
+ return ok({
1668
+ command,
1669
+ run_as: "background process (do not wait for it inline)",
1670
+ how_to: "Run `command` in the background. On exit 0, parse stdout: " +
1671
+ "has_updates:true names the room to catch_up; has_updates:false is a " +
1672
+ "normal quiet deadline. Exit 2 is an error/duplicate watcher. Re-arm " +
1673
+ "only if still needed. The exit is only an OS signal; whether it " +
1674
+ "wakes YOU depends on the harness.",
1675
+ exit_codes: {
1676
+ "0": "normal completion; inspect stdout has_updates",
1677
+ "124": "quiet timeout only for direct CLI without --ok-on-timeout",
1678
+ "2": "error or equivalent watcher already running",
1679
+ },
1680
+ baselined: false,
1681
+ single_process: true,
1682
+ });
1683
+ }
1684
+ catch (e) {
1685
+ return fail(asMessage(e));
1686
+ }
1687
+ });
1688
+ server.registerTool("read_history", {
1689
+ title: "Read history",
1690
+ description: "Browse messages WITHOUT changing your read marker. No before_seq = the " +
1691
+ "most recent `limit` messages; page backward with before_seq = the " +
1692
+ "prior call's oldest_seq. Oldest-first; replies carry a `reply_to` " +
1693
+ "preview. For unread directed messages use my_mentions; to find a " +
1694
+ "topic use search_messages.",
1695
+ inputSchema: z.object({
1696
+ limit: z
1697
+ .number()
1698
+ .int()
1699
+ .positive()
1700
+ .max(500)
1701
+ .optional()
1702
+ .describe("How many messages to return (default 50)"),
1703
+ before_seq: z
1704
+ .number()
1705
+ .int()
1706
+ .positive()
1707
+ .optional()
1708
+ .describe("Return messages older than this seq (for pagination)"),
1709
+ mentions_me: z
1710
+ .boolean()
1711
+ .optional()
1712
+ .describe("REMOVED in v0.6.0; use my_mentions. Passing it is an error."),
1713
+ preview_chars: z
1714
+ .number()
1715
+ .int()
1716
+ .positive()
1717
+ .optional()
1718
+ .describe("Truncate each body to this many chars; cut bodies carry " +
1719
+ "truncated:true + length (full body via get_message). Truncated " +
1720
+ "json = partial string, not an object."),
1721
+ max_bytes: z
1722
+ .number()
1723
+ .int()
1724
+ .min(1000)
1725
+ .max(400_000)
1726
+ .optional()
1727
+ .describe("Serialized-size budget per page (default 100000); " +
1728
+ "byte_limited:true = trimmed, continue with before_seq."),
1729
+ }).strict(),
1730
+ }, async ({ limit, before_seq, mentions_me, preview_chars, max_bytes }) => {
1731
+ try {
1732
+ touchSession();
1733
+ if (mentions_me !== undefined) {
1734
+ return fail("mentions_me was removed from read_history in v0.6.0; use " +
1735
+ "my_mentions for the cross-room directed inbox or search_messages " +
1736
+ "to find topics.");
1737
+ }
1738
+ const { roomId } = requireActive();
1739
+ return ok(store.readHistory(roomId, limit ?? 50, before_seq, preview_chars, max_bytes));
1740
+ }
1741
+ catch (e) {
1742
+ return fail(asMessage(e));
1743
+ }
1744
+ });
1745
+ server.registerTool("mark_read", {
1746
+ title: "Mark read",
1747
+ description: "Advance (or rewind) your read marker WITHOUT returning messages. Omit " +
1748
+ "`seq` to jump to the latest (skip the backlog); a lower `seq` " +
1749
+ "re-exposes messages to catch_up. Nothing is deleted; read_history " +
1750
+ "still sees everything. Returns previous/new marker and the latest seq.",
1751
+ inputSchema: z.object({
1752
+ seq: z
1753
+ .number()
1754
+ .int()
1755
+ .nonnegative()
1756
+ .optional()
1757
+ .describe("Marker target; omit to jump to the latest message"),
1758
+ }).strict(),
1759
+ }, async ({ seq }) => {
1760
+ try {
1761
+ touchSession();
1762
+ const { agentId, roomId } = requireActive();
1763
+ return ok(store.markRead(roomId, agentId, seq, cursorId()));
1764
+ }
1765
+ catch (e) {
1766
+ return fail(asMessage(e));
1767
+ }
1768
+ });
1769
+ server.registerTool("get_message", {
1770
+ title: "Get one message",
1771
+ description: "Fetch one message by seq (e.g. to resolve 'see message 8'). Bodies " +
1772
+ "return up to `max_chars` per call (escape-heavy bodies return fewer: " +
1773
+ "the SERIALIZED slice honors max_chars too); longer ones arrive sliced " +
1774
+ "with `length`, `offset`, and `next_offset`. truncated:true = more " +
1775
+ "remains BEYOND the slice: call again with offset = next_offset until " +
1776
+ "truncated is false. offset/length/next_offset count CHARACTERS " +
1777
+ "(codepoints). A sliced json body is a raw partial string. Pass `room` " +
1778
+ "when expanding a result from catch_up({room}) or my_mentions; seqs " +
1779
+ "are per-room and omission reads the active room.",
1780
+ inputSchema: z.object({
1781
+ room: z
1782
+ .string()
1783
+ .min(1)
1784
+ .max(500)
1785
+ .optional()
1786
+ .describe("Read a room you have joined (id or name) without changing the " +
1787
+ "active room; omit to use the active room"),
1788
+ seq: z.number().int().positive().describe("Message number to fetch"),
1789
+ offset: z
1790
+ .number()
1791
+ .int()
1792
+ .nonnegative()
1793
+ .optional()
1794
+ .describe("Character offset to start the body slice at (default 0)"),
1795
+ max_chars: z
1796
+ .number()
1797
+ .int()
1798
+ .min(100)
1799
+ .max(400_000)
1800
+ .optional()
1801
+ .describe("Max body characters to return (default 100000)"),
1802
+ }).strict(),
1803
+ }, async ({ room, seq, offset, max_chars }) => {
1804
+ try {
1805
+ touchSession();
1806
+ const { roomId, roomName } = resolveJoinedRoom(room);
1807
+ if (session.agentId !== null) {
1808
+ touchCapturedRoom(roomId, session.agentId);
1809
+ }
1810
+ const msg = store.getMessage(roomId, seq, offset ?? 0, max_chars);
1811
+ if (!msg) {
1812
+ return fail(`no message ${seq} in room "${roomName ?? roomId}"`);
1813
+ }
1814
+ return ok(msg);
1815
+ }
1816
+ catch (e) {
1817
+ return fail(asMessage(e));
1818
+ }
1819
+ });
1820
+ server.registerTool("get_thread", {
1821
+ title: "Get thread",
1822
+ description: "Fetch a message with its parent and a bounded tree of its replies " +
1823
+ "(pre-order, `depth` field, 1 = direct reply; `max_depth` levels, " +
1824
+ "default 3; `replies_capped` flags the internal cap). One shared byte " +
1825
+ "budget covers message + parent + replies, so oversized bodies arrive " +
1826
+ "truncated:true with length; page full text via get_message using the " +
1827
+ "same room. Pass `room` for a cross-room result; omission uses the " +
1828
+ "active room.",
1829
+ inputSchema: z.object({
1830
+ room: z
1831
+ .string()
1832
+ .min(1)
1833
+ .max(500)
1834
+ .optional()
1835
+ .describe("Read a room you have joined (id or name) without changing the " +
1836
+ "active room; omit to use the active room"),
1837
+ seq: z.number().int().positive().describe("Message number to expand"),
1838
+ max_depth: z
1839
+ .number()
1840
+ .int()
1841
+ .positive()
1842
+ .max(10)
1843
+ .optional()
1844
+ .describe("Reply levels to walk (default 3, max 10)"),
1845
+ preview_chars: z
1846
+ .number()
1847
+ .int()
1848
+ .positive()
1849
+ .optional()
1850
+ .describe("Truncate reply bodies to this many characters"),
1851
+ }).strict(),
1852
+ }, async ({ room, seq, max_depth, preview_chars }) => {
1853
+ try {
1854
+ touchSession();
1855
+ const { roomId, roomName } = resolveJoinedRoom(room);
1856
+ if (session.agentId !== null) {
1857
+ touchCapturedRoom(roomId, session.agentId);
1858
+ }
1859
+ const thread = store.getThread(roomId, seq, max_depth ?? 3, preview_chars);
1860
+ if (!thread) {
1861
+ return fail(`no message ${seq} in room "${roomName ?? roomId}"`);
1862
+ }
1863
+ return ok(thread);
1864
+ }
1865
+ catch (e) {
1866
+ return fail(asMessage(e));
1867
+ }
1868
+ });
1869
+ server.registerTool("set_room_intro", {
1870
+ title: "Set room intro",
1871
+ description: "Set or update the pinned intro/conventions for the active room. Pass an " +
1872
+ "empty string to clear it. Joiners see this in join_room.",
1873
+ inputSchema: z.object({
1874
+ text: z
1875
+ .string()
1876
+ .max(10_000)
1877
+ .describe("Pinned intro text (empty string clears it)"),
1878
+ }).strict(),
1879
+ }, async ({ text }) => {
1880
+ try {
1881
+ touchSession();
1882
+ const { roomId } = requireActive();
1883
+ const value = text.length > 0 ? text : null;
1884
+ store.setPinned(roomId, value);
1885
+ return ok({ room_id: roomId, pinned: value });
1886
+ }
1887
+ catch (e) {
1888
+ return fail(asMessage(e));
1889
+ }
1890
+ });
1891
+ server.registerTool("search_messages", {
1892
+ title: "Search messages",
1893
+ description: "Full-text search of message bodies in the active room, best matches " +
1894
+ "first. `query` is FTS5 syntax: bare terms are ANDed; supports OR, NOT, " +
1895
+ 'quoted "phrases", and prefix* . Use this instead of paging read_history ' +
1896
+ "to find where a topic was discussed. `next_offset` present = more " +
1897
+ "matches exist (a byte cut or the limit); pass it back as `offset` to " +
1898
+ "page the rest.",
1899
+ inputSchema: z.object({
1900
+ query: z.string().min(1).max(1000).describe("FTS5 search query"),
1901
+ limit: z
1902
+ .number()
1903
+ .int()
1904
+ .positive()
1905
+ .max(100)
1906
+ .optional()
1907
+ .describe("Max results (default 20)"),
1908
+ offset: z
1909
+ .number()
1910
+ .int()
1911
+ .nonnegative()
1912
+ .optional()
1913
+ .describe("Skip this many best matches (prior page's next_offset)"),
1914
+ }).strict(),
1915
+ }, async ({ query, limit, offset }) => {
1916
+ try {
1917
+ touchSession();
1918
+ const { roomId } = requireActive();
1919
+ return ok(store.searchMessages(roomId, query, limit ?? 20, offset ?? 0));
1920
+ }
1921
+ catch (e) {
1922
+ return fail(asMessage(e));
1923
+ }
1924
+ });
1925
+ server.registerTool("claim", {
1926
+ title: "Claim a resource",
1927
+ description: "Claim exclusive (advisory) ownership of a named resource BEFORE " +
1928
+ "working on it, e.g. 'file:src/db.ts' or 'task:B-414'. Atomic " +
1929
+ "single-winner (unlike 'I claim X' chat posts, which can cross). " +
1930
+ "Returns granted:true with RFC3339-UTC expires_at, or granted:false with the " +
1931
+ "holder. Claims expire after ttl_seconds (default 900); re-claim your " +
1932
+ "own key to renew. Advisory only: nothing is physically locked, " +
1933
+ "cooperating agents must check. Ownership is per agent_id. Pass room " +
1934
+ "to operate in another joined room without changing the active room.",
1935
+ inputSchema: z.object({
1936
+ room: z
1937
+ .string()
1938
+ .min(1)
1939
+ .max(500)
1940
+ .optional()
1941
+ .describe("Room id or name you have joined; omit to use the active room"),
1942
+ key: z
1943
+ .string()
1944
+ .min(1)
1945
+ .max(500)
1946
+ .describe("Resource name, e.g. 'file:src/db.ts' or 'task:refactor-x'"),
1947
+ ttl_seconds: z
1948
+ .number()
1949
+ .int()
1950
+ .positive()
1951
+ .max(86_400)
1952
+ .optional()
1953
+ .describe("Claim lifetime in seconds (default 900 = 15 minutes)"),
1954
+ note: z
1955
+ .string()
1956
+ .max(2000)
1957
+ .optional()
1958
+ .describe("What you are doing with it (shown to other agents)"),
1959
+ }).strict(),
1960
+ }, async ({ room, key, ttl_seconds, note }) => {
1961
+ try {
1962
+ touchSession();
1963
+ const { agentId, roomId, roomName } = resolveJoinedRoom(room);
1964
+ touchCapturedRoom(roomId, agentId);
1965
+ return ok({
1966
+ room_id: roomId,
1967
+ room_name: roomName,
1968
+ ...store.claimResource(roomId, key, agentId, ttl_seconds ?? 900, note ?? null),
1969
+ });
1970
+ }
1971
+ catch (e) {
1972
+ return fail(asMessage(e));
1973
+ }
1974
+ });
1975
+ server.registerTool("release_claim", {
1976
+ title: "Release a claim",
1977
+ description: "Release a claim you hold so others can take it. Expired claims can be " +
1978
+ "released by anyone; an active claim only by its holder. Pass room to " +
1979
+ "operate in another joined room without changing the active room.",
1980
+ inputSchema: z.object({
1981
+ room: z
1982
+ .string()
1983
+ .min(1)
1984
+ .max(500)
1985
+ .optional()
1986
+ .describe("Room id or name you have joined; omit to use the active room"),
1987
+ key: z.string().min(1).max(500).describe("Resource name to release"),
1988
+ }).strict(),
1989
+ }, async ({ room, key }) => {
1990
+ try {
1991
+ touchSession();
1992
+ const { agentId, roomId, roomName } = resolveJoinedRoom(room);
1993
+ touchCapturedRoom(roomId, agentId);
1994
+ return ok({
1995
+ room_id: roomId,
1996
+ room_name: roomName,
1997
+ ...store.releaseClaim(roomId, key, agentId),
1998
+ });
1999
+ }
2000
+ catch (e) {
2001
+ return fail(asMessage(e));
2002
+ }
2003
+ });
2004
+ server.registerTool("list_claims", {
2005
+ title: "List claims",
2006
+ description: "List active (unexpired) claims in the active or named joined room " +
2007
+ "without changing the active room (up to `limit`, " +
2008
+ "`total` active count rides along): key, holder, note (listing preview, " +
2009
+ "note_truncated flags a cut), and seconds until expiry. Check before " +
2010
+ "starting work that overlaps someone's claim. `next_key` present = more " +
2011
+ "rows exist; page by passing it back as `after_key` (keyset paging, so a " +
2012
+ "claim expiring between pages cannot make you skip a live one). " +
2013
+ "expires_at is RFC3339 UTC; expires_in_seconds is relative.",
2014
+ inputSchema: z
2015
+ .object({
2016
+ room: z
2017
+ .string()
2018
+ .min(1)
2019
+ .max(500)
2020
+ .optional()
2021
+ .describe("Room id or name you have joined; omit to use the active room"),
2022
+ limit: z
2023
+ .number()
2024
+ .int()
2025
+ .positive()
2026
+ .max(1000)
2027
+ .optional()
2028
+ .describe("Max claims to return (default 200)"),
2029
+ after_key: z
2030
+ .string()
2031
+ .max(500)
2032
+ .optional()
2033
+ .describe("Keyset paging cursor: the prior page's next_key. Returns claims " +
2034
+ "whose key sorts after it."),
2035
+ })
2036
+ .strict(),
2037
+ }, async ({ room, limit, after_key }) => {
2038
+ try {
2039
+ touchSession();
2040
+ const { agentId, roomId, roomName } = resolveJoinedRoom(room);
2041
+ touchCapturedRoom(roomId, agentId);
2042
+ const { claims, total, next_key, size_trimmed } = store.listClaims(roomId, limit ?? 200, after_key ?? "");
2043
+ return ok({
2044
+ room_id: roomId,
2045
+ room_name: roomName,
2046
+ claims,
2047
+ total,
2048
+ ...(next_key !== undefined ? { next_key, truncated: true } : {}),
2049
+ ...(size_trimmed ? { size_trimmed: true } : {}),
2050
+ });
2051
+ }
2052
+ catch (e) {
2053
+ return fail(asMessage(e));
2054
+ }
2055
+ });
2056
+ server.registerTool("prune_messages", {
2057
+ title: "Prune messages",
2058
+ description: "Delete the oldest messages in the active room, keeping the newest " +
2059
+ "`keep_last` (kept seqs and future numbering unchanged). Destructive, " +
2060
+ "not reversible. By default REFUSES (refused:true with " +
2061
+ "would_delete_unread/min_read_seq) if any non-author member has not " +
2062
+ "read a doomed message yet, including members that left and lagging " +
2063
+ "private session cursors; force=true prunes anyway.",
2064
+ inputSchema: z.object({
2065
+ keep_last: z
2066
+ .number()
2067
+ .int()
2068
+ .positive()
2069
+ .describe("How many of the newest messages to keep"),
2070
+ force: z
2071
+ .boolean()
2072
+ .optional()
2073
+ .describe("Delete even messages a member (present or left) has not read yet"),
2074
+ }).strict(),
2075
+ }, async ({ keep_last, force }) => {
2076
+ try {
2077
+ touchSession();
2078
+ const { roomId } = requireActive();
2079
+ return ok({
2080
+ room_id: roomId,
2081
+ ...store.pruneMessages(roomId, keep_last, force ?? false),
2082
+ });
2083
+ }
2084
+ catch (e) {
2085
+ return fail(asMessage(e));
2086
+ }
2087
+ });
2088
+ server.registerTool("delete_room", {
2089
+ title: "Delete room",
2090
+ description: "Permanently delete a room (by id or name) and ALL related data " +
2091
+ "(messages, memberships, read positions, claims). Requires " +
2092
+ "confirm=true. Destructive, not reversible, unauthenticated: any caller " +
2093
+ "can delete any room. Returns the removed counts.",
2094
+ inputSchema: z.object({
2095
+ room: z.string().min(1).max(500).describe("Room id or name to delete"),
2096
+ confirm: z
2097
+ .boolean()
2098
+ .describe("Must be true; a guard against accidental deletion"),
2099
+ }).strict(),
2100
+ }, async ({ room, confirm }) => {
2101
+ try {
2102
+ touchSession();
2103
+ const target = store.resolveRoom(room);
2104
+ if (!target)
2105
+ return fail(`no room "${room}"`);
2106
+ if (confirm !== true) {
2107
+ return fail(`pass confirm:true to delete room ${target.id} ("${target.name}")`);
2108
+ }
2109
+ const result = store.deleteRoom(target.id);
2110
+ // Drop every identity's private-mode entry for the dead room.
2111
+ // (Deleting the current element during Set iteration is well-defined.)
2112
+ for (const k of session.privateRooms) {
2113
+ if (k.startsWith(`${target.id}\u0000`))
2114
+ session.privateRooms.delete(k);
2115
+ }
2116
+ if (session.roomId === target.id) {
2117
+ session.roomId = null; // identity survives; only the room is gone
2118
+ }
2119
+ return ok({ deleted_room: target.id, name: target.name, ...result });
2120
+ }
2121
+ catch (e) {
2122
+ return fail(asMessage(e));
2123
+ }
2124
+ });
2125
+ // Readable identity generation for agents that omit agent_id. Two short word
2126
+ // lists give ~676 base combinations; a hex suffix (then a UUID fallback)
2127
+ // guarantees a free id even under collision. The id is claimed atomically via
2128
+ // tryCreateAgent so concurrent assigners cannot land on the same identity.
2129
+ const ID_ADJECTIVES = [
2130
+ "amber", "brisk", "calm", "clever", "cobalt", "copper", "deft", "eager",
2131
+ "fern", "gilded", "hardy", "ivory", "jade", "keen", "lucid", "mellow",
2132
+ "nimble", "olive", "prime", "quiet", "rapid", "sable", "teal", "umber",
2133
+ "vivid", "warm",
2134
+ ];
2135
+ const ID_NOUNS = [
2136
+ "otter", "falcon", "cedar", "harbor", "lynx", "maple", "comet", "delta",
2137
+ "ember", "fjord", "grove", "heron", "inlet", "kite", "larch", "mesa",
2138
+ "nimbus", "onyx", "pike", "quartz", "ridge", "summit", "tundra", "vale",
2139
+ "willow", "yarrow",
2140
+ ];
2141
+ function pick(xs) {
2142
+ return xs[Math.floor(Math.random() * xs.length)];
2143
+ }
2144
+ /** Assign and atomically claim a readable, collision-free agent id. */
2145
+ function assignReadableId(type, role, description) {
2146
+ for (let i = 0; i < 30; i++) {
2147
+ const base = `${pick(ID_ADJECTIVES)}-${pick(ID_NOUNS)}`;
2148
+ // After a run of plain-name misses, widen the space with a short suffix.
2149
+ const id = i < 12 ? base : `${base}-${randomUUID().slice(0, 4)}`;
2150
+ if (store.tryCreateAgent(id, type, role, description))
2151
+ return id;
2152
+ }
2153
+ // Last resort: keep drawing until an id is actually CLAIMED. Returning an
2154
+ // unclaimed id here silently adopted an EXISTING identity (shared read
2155
+ // markers and claims); ids are self-asserted, so a collision is improbable
2156
+ // but not impossible.
2157
+ for (let i = 0; i < 30; i++) {
2158
+ const id = `agent-${randomUUID().slice(0, 8)}`;
2159
+ if (store.tryCreateAgent(id, type, role, description))
2160
+ return id;
2161
+ }
2162
+ throw new Error("could not allocate a generated agent id; pass an explicit agent_id");
2163
+ }
2164
+ function dedupe(xs) {
2165
+ return [...new Set(xs)];
2166
+ }
2167
+ /** Room-local inactivity threshold for a factual pre-existing-backlog warning.
2168
+ * Chosen well past the 5-minute `active` window to avoid routine idle noise. */
2169
+ const DELIVERY_STALL_SECONDS = 1800;
2170
+ /** Human-readable idle duration for delivery warnings ("2h05m", "45m", "90s"). */
2171
+ function fmtIdle(seconds) {
2172
+ const s = Math.max(0, Math.floor(seconds));
2173
+ if (s < 120)
2174
+ return `${s}s`;
2175
+ const m = Math.floor(s / 60);
2176
+ if (m < 60)
2177
+ return `${m}m`;
2178
+ const h = Math.floor(m / 60);
2179
+ return `${h}h${String(m % 60).padStart(2, "0")}m`;
2180
+ }
2181
+ function asMessage(e) {
2182
+ return e instanceof Error ? e.message : String(e);
2183
+ }
2184
+ let shutdownPromise = null;
2185
+ function shutdown(code, message) {
2186
+ if (shutdownPromise)
2187
+ return shutdownPromise;
2188
+ process.exitCode = code;
2189
+ if (message)
2190
+ process.stderr.write(`${message}\n`);
2191
+ shutdownPromise = (async () => {
2192
+ // Keep a hard bound even if a third-party transport stops honoring close.
2193
+ const forcedExit = setTimeout(() => process.exit(code), 3_000);
2194
+ try {
2195
+ // Transport close synchronously aborts every SDK request signal. Wait for
2196
+ // their finally blocks (notably wait-lease deletion) before closing the
2197
+ // shared store, so EOF cannot leave a wait alive or advance afterward.
2198
+ await server.close().catch(() => undefined);
2199
+ await Promise.allSettled([...activeToolRequests]);
2200
+ try {
2201
+ store.close();
2202
+ }
2203
+ catch { }
2204
+ }
2205
+ finally {
2206
+ clearTimeout(forcedExit);
2207
+ process.exit(code);
2208
+ }
2209
+ })();
2210
+ return shutdownPromise;
2211
+ }
2212
+ async function main() {
2213
+ // The SDK's stock ReadBuffer has no frame cap and repeatedly concatenates a
2214
+ // growing partial line. Feed it complete, size-bounded lines instead: its
2215
+ // supported custom-stdin constructor still owns protocol parsing/writes.
2216
+ const boundedInput = new BoundedLineTransform();
2217
+ boundedInput.once("error", (error) => {
2218
+ process.stdin.unpipe(boundedInput);
2219
+ process.stdin.pause();
2220
+ // A frame this large cannot be parsed safely enough to recover its request
2221
+ // id. Close the owned transport and terminate instead of leaving the MCP
2222
+ // client waiting on a half-open connection.
2223
+ void shutdown(1, `fatal: ${asMessage(error)}`);
2224
+ });
2225
+ // The SDK's stdio transport does not observe EOF. Closing it here aborts
2226
+ // in-flight waits before they can consume a message for a dead client.
2227
+ boundedInput.once("end", () => void shutdown(0));
2228
+ process.stdout.once("error", (error) => {
2229
+ void shutdown(1, `fatal: stdout disconnected: ${asMessage(error)}`);
2230
+ });
2231
+ process.stderr.once("error", () => void shutdown(1));
2232
+ const transport = new StdioServerTransport(boundedInput, process.stdout);
2233
+ await server.connect(transport);
2234
+ process.stdin.once("error", (error) => boundedInput.destroy(error));
2235
+ process.stdin.pipe(boundedInput);
2236
+ // stdio transport: do not write to stdout; it carries the JSON-RPC stream.
2237
+ process.stderr.write(`agent-chat-mcp ready (db: ${store.path})\n`);
2238
+ }
2239
+ main().catch((e) => {
2240
+ void shutdown(1, `fatal: ${asMessage(e)}`);
2241
+ });