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