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