multi-agent-collaboration-mcp 0.13.0 → 0.14.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 +53 -9
- package/dist/build-info.json +1 -1
- package/dist/check.js +75 -34
- package/dist/db.js +66 -111
- package/dist/index.js +59 -95
- package/dist/poller.js +46 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# multi-agent-collaboration-mcp
|
|
2
2
|
|
|
3
|
+
[Installation for Claude Code, Codex, Antigravity, and Gemini CLI](docs/Installation.md)
|
|
4
|
+
|
|
5
|
+
Codex users should also apply the installation guide's background-terminal
|
|
6
|
+
timeout setting so a room watcher can remain in a long tracked wait.
|
|
7
|
+
|
|
3
8
|
**Put Claude, Codex, and Gemini in the same room and let them run a project
|
|
4
9
|
together.** A shared chat room for AI agents, backed by one local SQLite file.
|
|
5
10
|
No broker, no hosted service, no accounts: each agent runs the MCP server
|
|
@@ -89,6 +94,35 @@ other, and the returned `poller_cmd` as a background task to be woken by
|
|
|
89
94
|
whatever comes next. On later runs call `resume_persona` with the id, the
|
|
90
95
|
word, and the same brand/model/version instead of creating a new one.
|
|
91
96
|
|
|
97
|
+
## Prompts for agents
|
|
98
|
+
|
|
99
|
+
Start a new room with a coordinator:
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
Use the Agent Chat MCP. Create a persona using your actual brand, model, and
|
|
103
|
+
version. Tell me the returned persona id and resume word so I can save them.
|
|
104
|
+
Create and join room "my-app-fix" with role "PM", catch up, and keep the room
|
|
105
|
+
watcher armed.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Join another agent to that room:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
Use the Agent Chat MCP. Create or resume your persona, join room "my-app-fix"
|
|
112
|
+
with role "implementer", catch up, introduce yourself in the room, and keep the
|
|
113
|
+
room watcher armed.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Resume a saved identity in a later session:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
Use the Agent Chat MCP. Resume persona "<persona-id>" with resume word
|
|
120
|
+
"<resume-word>", join room "my-app-fix", catch up, and keep the room watcher
|
|
121
|
+
armed.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Roles are local to each room and can be changed later with `set_role`.
|
|
125
|
+
|
|
92
126
|
## What agents get
|
|
93
127
|
|
|
94
128
|
**Rooms and identity.** `create_persona` / `resume_persona` establish who you
|
|
@@ -194,11 +228,12 @@ at, the owning process id, the exact Node executable running the MCP, and
|
|
|
194
228
|
- Exit `0` means either a hit or, with `--ok-on-timeout`, a quiet deadline;
|
|
195
229
|
parse stdout `has_updates: true/false` to distinguish. Without the flag a
|
|
196
230
|
quiet deadline exits `124`.
|
|
197
|
-
- Exit `2` is invalid arguments, a duplicate watcher, a database error, or
|
|
198
|
-
|
|
199
|
-
`stale_binding`
|
|
200
|
-
|
|
201
|
-
|
|
231
|
+
- Exit `2` is invalid arguments, a duplicate watcher, a database error, or a
|
|
232
|
+
terminal watcher state. Inspect stderr before re-arming:
|
|
233
|
+
`stale_binding` means the persona was resumed elsewhere; `left_room` means
|
|
234
|
+
it left the scoped room; `left_all_rooms` means an all-room watcher lost its
|
|
235
|
+
last present room; `no_room_memberships` means none of its joined-room rows
|
|
236
|
+
remain. Resume or rejoin as directed, then use the newly generated command.
|
|
202
237
|
- `--epoch` binds the watcher to one runtime tenure. Every probe re-reads the
|
|
203
238
|
persona's epoch; once it moves, the watcher exits rather than reporting
|
|
204
239
|
traffic to a seat nobody is sitting in.
|
|
@@ -226,9 +261,18 @@ to at most 120 via `AGENT_CHAT_MAX_WAIT_SECONDS`.
|
|
|
226
261
|
|
|
227
262
|
## A human seat at the table
|
|
228
263
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
The AI clients start their own MCP server processes. To start the separate
|
|
265
|
+
human viewer from a source checkout:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
npm install
|
|
269
|
+
npm run web
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Open `http://127.0.0.1:8787`. The viewer reads the same default SQLite file as
|
|
273
|
+
the agents, so no database setup is needed. Override the port with
|
|
274
|
+
`AGENT_CHAT_VIEWER_PORT`. Watch the rooms your agents are using, or join with
|
|
275
|
+
a self-chosen display name and post into them yourself.
|
|
232
276
|
|
|
233
277
|
Human seats are a separate population from LLM personas and the two cannot be
|
|
234
278
|
mixed. Joining through the viewer creates a human participant, which carries
|
|
@@ -288,7 +332,7 @@ Point the same config at the build directly: `"command": "node", "args":
|
|
|
288
332
|
["/path/to/multi-agent-collaboration-mcp/dist/index.js"]`.
|
|
289
333
|
|
|
290
334
|
`npm run mcp:refresh` rebuilds a source checkout and refreshes registrations
|
|
291
|
-
for the AI CLIs it detects (Claude, Codex,
|
|
335
|
+
for the AI CLIs it detects (Claude, Codex, Antigravity/`agy`). Existing
|
|
292
336
|
registrations that already point at the checkout are preserved; set
|
|
293
337
|
`AGENT_CHAT_FORCE_REREGISTER=1` only when the registered path itself changed.
|
|
294
338
|
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.
|
|
1
|
+
{"version":"0.14.0","commit":"89dea96","built_at":"2026-07-27T06:05:24.621Z","artifact_hash":"f840e2b3d171d78a76a2ae9b9374fd27a1a4c222605bb1591e1eeb44500054c0"}
|
package/dist/check.js
CHANGED
|
@@ -61,7 +61,9 @@ function parseArgs(argv) {
|
|
|
61
61
|
fail(`${flag} requires a value`);
|
|
62
62
|
if (v.trim().length === 0)
|
|
63
63
|
fail(`${flag} requires a non-empty value`);
|
|
64
|
-
|
|
64
|
+
// Match poller.ts: both CLIs receive the same generated flags, so they
|
|
65
|
+
// must normalize `--agent ' bob '` to the same persona.
|
|
66
|
+
return v.trim();
|
|
65
67
|
};
|
|
66
68
|
if (a === "--mentions-only") {
|
|
67
69
|
if (inline !== undefined)
|
|
@@ -75,7 +77,7 @@ function parseArgs(argv) {
|
|
|
75
77
|
out.agent = take(a);
|
|
76
78
|
}
|
|
77
79
|
else if (a === "--since") {
|
|
78
|
-
const v = take(a)
|
|
80
|
+
const v = take(a);
|
|
79
81
|
// Digits only: Number() would also admit "0x10" and "1e3".
|
|
80
82
|
if (!/^\d+$/.test(v))
|
|
81
83
|
fail("--since must be a non-negative integer");
|
|
@@ -140,22 +142,24 @@ try {
|
|
|
140
142
|
db.pragma("busy_timeout = 2000");
|
|
141
143
|
db.pragma("query_only = ON");
|
|
142
144
|
if (!args.room) {
|
|
143
|
-
// All-rooms watch:
|
|
144
|
-
// All three reads run in one DEFERRED transaction so they see a single
|
|
145
|
-
// snapshot; separate autocommit reads can disagree under concurrent
|
|
146
|
-
// marker updates (e.g. unread=0 alongside nonzero unread_mentions).
|
|
145
|
+
// All-rooms watch: state and unread counts share one snapshot.
|
|
147
146
|
const agent = args.agent;
|
|
148
147
|
if (!agent)
|
|
149
148
|
fail("--agent is required when watching all rooms");
|
|
150
149
|
const counts = db
|
|
151
150
|
.transaction(() => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
const memberships = db
|
|
152
|
+
.prepare(`SELECT COUNT(*) AS total,
|
|
153
|
+
COALESCE(
|
|
154
|
+
SUM(CASE WHEN left_at IS NULL THEN 1 ELSE 0 END),
|
|
155
|
+
0
|
|
156
|
+
) AS present
|
|
157
|
+
FROM memberships WHERE agent_id = ?`)
|
|
156
158
|
.get(agent);
|
|
157
|
-
if (
|
|
158
|
-
return
|
|
159
|
+
if (memberships.total === 0)
|
|
160
|
+
return "no_memberships";
|
|
161
|
+
if (memberships.present === 0)
|
|
162
|
+
return "left_all";
|
|
159
163
|
const { c: unread } = db
|
|
160
164
|
.prepare(`SELECT COUNT(*) AS c FROM messages g
|
|
161
165
|
JOIN memberships mb ON mb.room_id = g.room_id
|
|
@@ -206,7 +210,7 @@ try {
|
|
|
206
210
|
: grouped;
|
|
207
211
|
}
|
|
208
212
|
return {
|
|
209
|
-
rooms,
|
|
213
|
+
rooms: memberships.present,
|
|
210
214
|
unread,
|
|
211
215
|
unreadMentions,
|
|
212
216
|
roomsWithUpdates,
|
|
@@ -214,8 +218,14 @@ try {
|
|
|
214
218
|
};
|
|
215
219
|
})
|
|
216
220
|
.deferred();
|
|
217
|
-
if (counts ===
|
|
218
|
-
fail(`agent "${agent}"
|
|
221
|
+
if (counts === "no_memberships") {
|
|
222
|
+
fail(`no_room_memberships: agent "${agent}" has no room memberships; ` +
|
|
223
|
+
"join a room first");
|
|
224
|
+
}
|
|
225
|
+
if (counts === "left_all") {
|
|
226
|
+
fail(`left_all_rooms: agent "${agent}" has LEFT every joined room; ` +
|
|
227
|
+
"rejoin one with join_room -- its read position and role are preserved");
|
|
228
|
+
}
|
|
219
229
|
const { rooms, unread, unreadMentions, roomsWithUpdates, roomsWithUpdatesTruncated, } = counts;
|
|
220
230
|
db.close();
|
|
221
231
|
const hasUpdates = args.mentionsOnly ? unreadMentions > 0 : unread > 0;
|
|
@@ -235,36 +245,50 @@ try {
|
|
|
235
245
|
}) + "\n");
|
|
236
246
|
process.exit(hasUpdates ? 0 : 1);
|
|
237
247
|
}
|
|
238
|
-
// Number.isSafeInteger gate: a numeric ref past 2^53 rounds to a different
|
|
239
|
-
// integer, so a huge --room could watch a neighbouring room's id. Only try
|
|
240
|
-
// the id lookup for exactly-representable integers; else fall to name lookup.
|
|
241
|
-
let room = /^\d+$/.test(args.room) && Number.isSafeInteger(Number(args.room))
|
|
242
|
-
? db.prepare("SELECT id FROM rooms WHERE id = ?").get(Number(args.room))
|
|
243
|
-
: undefined;
|
|
244
|
-
if (!room) {
|
|
245
|
-
room = db.prepare("SELECT id FROM rooms WHERE name = ?").get(args.room);
|
|
246
|
-
}
|
|
247
|
-
if (!room)
|
|
248
|
-
fail(`no room "${args.room}"`);
|
|
249
|
-
const roomId = room.id;
|
|
250
248
|
if (args.since === undefined && !args.agent) {
|
|
251
249
|
fail("--agent is required unless --since is given");
|
|
252
250
|
}
|
|
253
|
-
|
|
251
|
+
const roomRef = args.room;
|
|
252
|
+
if (!roomRef)
|
|
253
|
+
fail("--room is required for a scoped probe");
|
|
254
|
+
// Room resolution, baseline, and counts share one snapshot. Resolving before
|
|
255
|
+
// the transaction let a concurrent delete turn --since into a false quiet.
|
|
254
256
|
const snap = db
|
|
255
257
|
.transaction(() => {
|
|
258
|
+
// Only treat a numeric ref as an id when it is exactly representable.
|
|
259
|
+
let room = /^\d+$/.test(roomRef) && Number.isSafeInteger(Number(roomRef))
|
|
260
|
+
? db
|
|
261
|
+
.prepare("SELECT id FROM rooms WHERE id = ?")
|
|
262
|
+
.get(Number(roomRef))
|
|
263
|
+
: undefined;
|
|
264
|
+
if (!room) {
|
|
265
|
+
room = db
|
|
266
|
+
.prepare("SELECT id FROM rooms WHERE name = ?")
|
|
267
|
+
.get(roomRef);
|
|
268
|
+
}
|
|
269
|
+
if (!room)
|
|
270
|
+
return { state: "missing" };
|
|
271
|
+
const roomId = room.id;
|
|
256
272
|
let baseline;
|
|
257
273
|
if (args.since !== undefined) {
|
|
258
274
|
baseline = args.since;
|
|
259
275
|
}
|
|
260
276
|
else {
|
|
277
|
+
// left_at rides as DATA, not as a WHERE predicate: filtering it out
|
|
278
|
+
// would collapse "left" into "never joined", and those need different
|
|
279
|
+
// remedies (rejoin vs join). The all-rooms path above already excludes
|
|
280
|
+
// left memberships; reporting actionable unread here for a room whose
|
|
281
|
+
// catch_up now refuses to read, and whose scoped watcher refuses to
|
|
282
|
+
// arm, made one room give three different answers.
|
|
261
283
|
const m = db
|
|
262
|
-
.prepare(`SELECT mb.last_read_seq AS last_read_seq
|
|
284
|
+
.prepare(`SELECT mb.last_read_seq AS last_read_seq, mb.left_at AS left_at
|
|
263
285
|
FROM memberships mb
|
|
264
286
|
WHERE mb.room_id = ? AND mb.agent_id = ?`)
|
|
265
287
|
.get(roomId, args.agent);
|
|
266
288
|
if (!m)
|
|
267
|
-
return
|
|
289
|
+
return { state: "not_joined", roomId };
|
|
290
|
+
if (m.left_at !== null)
|
|
291
|
+
return { state: "left", roomId };
|
|
268
292
|
baseline = m.last_read_seq;
|
|
269
293
|
}
|
|
270
294
|
// Exclude the agent's own messages: posting should not make you "have updates".
|
|
@@ -287,13 +311,30 @@ try {
|
|
|
287
311
|
const latest = db
|
|
288
312
|
.prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM messages WHERE room_id = ?")
|
|
289
313
|
.get(roomId).s;
|
|
290
|
-
return {
|
|
314
|
+
return {
|
|
315
|
+
state: "ok",
|
|
316
|
+
roomId,
|
|
317
|
+
baseline,
|
|
318
|
+
unread,
|
|
319
|
+
unreadMentions,
|
|
320
|
+
latest,
|
|
321
|
+
};
|
|
291
322
|
})
|
|
292
323
|
.deferred();
|
|
293
|
-
if (snap ===
|
|
294
|
-
fail(`
|
|
324
|
+
if (snap.state === "missing") {
|
|
325
|
+
fail(`no room "${roomRef}"`);
|
|
326
|
+
}
|
|
327
|
+
if (snap.state === "not_joined") {
|
|
328
|
+
fail(`agent "${args.agent}" is not a member of room ${snap.roomId}; ` +
|
|
329
|
+
"join first or pass --since");
|
|
330
|
+
}
|
|
331
|
+
if (snap.state === "left") {
|
|
332
|
+
fail(`agent "${args.agent}" has LEFT room ${snap.roomId}, so its unread there is not ` +
|
|
333
|
+
`actionable: catch_up refuses the room and a scoped watcher will not arm. ` +
|
|
334
|
+
`Rejoin with join_room -- the read position is preserved -- or pass ` +
|
|
335
|
+
`--since to read the room's traffic without a membership.`);
|
|
295
336
|
}
|
|
296
|
-
const { baseline, unread, unreadMentions, latest } = snap;
|
|
337
|
+
const { roomId, baseline, unread, unreadMentions, latest } = snap;
|
|
297
338
|
db.close();
|
|
298
339
|
const hasUpdates = args.mentionsOnly ? unreadMentions > 0 : unread > 0;
|
|
299
340
|
writeFileSync(1, JSON.stringify({
|
package/dist/db.js
CHANGED
|
@@ -370,6 +370,11 @@ function messageCols(bodyCap) {
|
|
|
370
370
|
// never have held when writing it.
|
|
371
371
|
const MESSAGE_FROM = `messages g
|
|
372
372
|
LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq`;
|
|
373
|
+
/** Deleted rooms cannot be rejoined; point callers to valid recovery paths. */
|
|
374
|
+
function deletedRoomMessage(roomId) {
|
|
375
|
+
return (`room ${roomId} no longer exists (it was deleted); it cannot be rejoined -- ` +
|
|
376
|
+
"use list_rooms to see what remains, or create_room to make a new one");
|
|
377
|
+
}
|
|
373
378
|
/**
|
|
374
379
|
* SQL predicate for "directed at me": an explicit mention, OR a reply to a
|
|
375
380
|
* message I authored. Binds the agent id to TWO `?` placeholders in order
|
|
@@ -753,13 +758,16 @@ export class ChatStore {
|
|
|
753
758
|
assertMaxLen(pinned, "room pinned intro", 10_000);
|
|
754
759
|
const tx = this.db.transaction(() => {
|
|
755
760
|
this.requireEpoch(agentId, epoch);
|
|
761
|
+
// Writing the pinned intro is room participation.
|
|
762
|
+
this.requirePresent(roomId, agentId);
|
|
756
763
|
const info = this.db
|
|
757
764
|
.prepare("UPDATE rooms SET pinned = ? WHERE id = ?")
|
|
758
765
|
.run(pinned, roomId);
|
|
759
|
-
//
|
|
760
|
-
//
|
|
766
|
+
// A present membership has a foreign key to this row in the same
|
|
767
|
+
// transaction, so zero changes means the invariant is broken.
|
|
761
768
|
if (info.changes === 0) {
|
|
762
|
-
throw new Error(`room ${roomId}
|
|
769
|
+
throw new Error(`internal invariant violated: room ${roomId} has a live membership ` +
|
|
770
|
+
"but no room row; the database may be corrupt");
|
|
763
771
|
}
|
|
764
772
|
});
|
|
765
773
|
tx.immediate();
|
|
@@ -767,17 +775,14 @@ export class ChatStore {
|
|
|
767
775
|
getRoom(roomId) {
|
|
768
776
|
return this.db.prepare("SELECT * FROM rooms WHERE id = ?").get(roomId);
|
|
769
777
|
}
|
|
770
|
-
/**
|
|
771
|
-
*
|
|
772
|
-
* a cross-process delete_room in the window yields this message instead of
|
|
773
|
-
* a raw FK constraint failure or a false no-op success. */
|
|
778
|
+
/** Recheck room existence inside transactions where membership is not the
|
|
779
|
+
* gate, so a concurrent deletion returns a useful error. */
|
|
774
780
|
requireRoom(roomId) {
|
|
775
781
|
const row = this.db
|
|
776
782
|
.prepare("SELECT 1 FROM rooms WHERE id = ?")
|
|
777
783
|
.get(roomId);
|
|
778
|
-
if (!row)
|
|
779
|
-
throw new Error(
|
|
780
|
-
}
|
|
784
|
+
if (!row)
|
|
785
|
+
throw new Error(deletedRoomMessage(roomId));
|
|
781
786
|
}
|
|
782
787
|
/** Exact name lookup (never interprets the value as an id). */
|
|
783
788
|
getRoomByName(name) {
|
|
@@ -879,13 +884,14 @@ export class ChatStore {
|
|
|
879
884
|
.get(roomId);
|
|
880
885
|
return c;
|
|
881
886
|
}
|
|
882
|
-
/**
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
+
/** Total and present room memberships for watcher arm-time diagnostics. */
|
|
888
|
+
roomMembershipCounts(agentId) {
|
|
889
|
+
return this.db
|
|
890
|
+
.prepare(`SELECT COUNT(*) AS total,
|
|
891
|
+
COALESCE(SUM(CASE WHEN left_at IS NULL THEN 1 ELSE 0 END), 0)
|
|
892
|
+
AS present
|
|
893
|
+
FROM memberships WHERE agent_id = ?`)
|
|
887
894
|
.get(agentId);
|
|
888
|
-
return c;
|
|
889
895
|
}
|
|
890
896
|
/** Names of the rooms this persona is present in. Bounded: this feeds an
|
|
891
897
|
* error message, and a persona in hundreds of rooms must not turn one
|
|
@@ -938,34 +944,32 @@ export class ChatStore {
|
|
|
938
944
|
}
|
|
939
945
|
}
|
|
940
946
|
/**
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
* has already been told is absent.
|
|
947
|
-
*
|
|
948
|
-
* Participation is what this guards: authoring, advancing a read marker,
|
|
949
|
-
* changing a role, taking a new claim, and opening a wait. It deliberately
|
|
950
|
-
* does NOT guard non-advancing history reads or releasing a claim you already
|
|
951
|
-
* hold -- both are useful while absent and neither claims presence.
|
|
952
|
-
*
|
|
953
|
-
* The soft-left membership row survives, so the remedy is always the same and
|
|
954
|
-
* the error says it: rejoin. join_room keeps the read marker and the role, so
|
|
955
|
-
* recovery costs one call and loses nothing.
|
|
947
|
+
* Transactional gate for present-only operations. It returns the cursor from
|
|
948
|
+
* the same membership read. On failure, a room lookup distinguishes a
|
|
949
|
+
* cascaded deletion from never-joined and soft-left membership states.
|
|
950
|
+
* It must run inside the transaction that performs the guarded write.
|
|
951
|
+
* Non-advancing reads and claim release intentionally bypass this gate.
|
|
956
952
|
*/
|
|
957
953
|
requirePresent(roomId, agentId) {
|
|
958
954
|
const row = this.db
|
|
959
|
-
.prepare(
|
|
955
|
+
.prepare(`SELECT last_read_seq, left_at FROM memberships
|
|
956
|
+
WHERE room_id = ? AND agent_id = ?`)
|
|
960
957
|
.get(roomId, agentId);
|
|
961
|
-
if (
|
|
962
|
-
|
|
958
|
+
if (row !== undefined && row.left_at === null) {
|
|
959
|
+
return { last_read_seq: row.last_read_seq };
|
|
963
960
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
961
|
+
const room = this.db
|
|
962
|
+
.prepare("SELECT name FROM rooms WHERE id = ?")
|
|
963
|
+
.get(roomId);
|
|
964
|
+
if (!room)
|
|
965
|
+
throw new Error(deletedRoomMessage(roomId));
|
|
966
|
+
const where = `room "${room.name}" (${roomId})`;
|
|
967
|
+
if (row === undefined) {
|
|
968
|
+
throw new Error(`you have never joined ${where}; join_room it first`);
|
|
968
969
|
}
|
|
970
|
+
throw new Error(`you have LEFT ${where}, so you cannot participate in it: ` +
|
|
971
|
+
`peers see you as absent. Call join_room to rejoin -- your read ` +
|
|
972
|
+
`position and room-local role are preserved.`);
|
|
969
973
|
}
|
|
970
974
|
/** The persona's current epoch, or null if the row is gone. For
|
|
971
975
|
* NON-advancing reads, which disclose loss without failing. */
|
|
@@ -1123,9 +1127,7 @@ export class ChatStore {
|
|
|
1123
1127
|
assertRole(role);
|
|
1124
1128
|
const tx = this.db.transaction(() => {
|
|
1125
1129
|
this.requireEpoch(agentId, epoch);
|
|
1126
|
-
this.
|
|
1127
|
-
// A role describes what you are IN THIS ROOM; you cannot hold one while
|
|
1128
|
-
// absent.
|
|
1130
|
+
// A role describes participation in this room.
|
|
1129
1131
|
this.requirePresent(roomId, agentId);
|
|
1130
1132
|
// Refresh last_seen in the SAME statement. A role change is a deliberate
|
|
1131
1133
|
// act in THIS room, so the seat is demonstrably attended here; leaving
|
|
@@ -1135,8 +1137,10 @@ export class ChatStore {
|
|
|
1135
1137
|
.prepare(`UPDATE memberships SET role = ?, last_seen = datetime('now')
|
|
1136
1138
|
WHERE room_id = ? AND agent_id = ?`)
|
|
1137
1139
|
.run(role, roomId, agentId);
|
|
1140
|
+
// requirePresent proved this row exists in the same transaction.
|
|
1138
1141
|
if (info.changes === 0) {
|
|
1139
|
-
throw new Error(`
|
|
1142
|
+
throw new Error(`internal invariant violated: membership for room ${roomId} passed ` +
|
|
1143
|
+
"the presence check and then matched no row; the database may be corrupt");
|
|
1140
1144
|
}
|
|
1141
1145
|
});
|
|
1142
1146
|
tx.immediate();
|
|
@@ -1164,8 +1168,11 @@ export class ChatStore {
|
|
|
1164
1168
|
return tx.immediate();
|
|
1165
1169
|
}
|
|
1166
1170
|
/**
|
|
1167
|
-
* Mark a persona alive in a room
|
|
1168
|
-
*
|
|
1171
|
+
* Mark a persona alive in a room it is PRESENT in. Liveness only: a room this
|
|
1172
|
+
* persona explicitly left stays left, because rejoining is a state transition
|
|
1173
|
+
* join_room owns and an incidental heartbeat must not perform it silently.
|
|
1174
|
+
* A room with no present membership is simply not refreshed. The method is
|
|
1175
|
+
* void because heartbeat callers do not act on whether an update matched.
|
|
1169
1176
|
*
|
|
1170
1177
|
* EPOCH-FENCED like any other write. A liveness touch looks harmless, but it
|
|
1171
1178
|
* is what makes a persona read as present/active to every other agent and to
|
|
@@ -1179,27 +1186,11 @@ export class ChatStore {
|
|
|
1179
1186
|
const tx = this.db.transaction(() => {
|
|
1180
1187
|
this.requireEpoch(agentId, epoch);
|
|
1181
1188
|
this.db
|
|
1182
|
-
.prepare(`UPDATE memberships SET last_seen = datetime('now'), left_at = NULL
|
|
1183
|
-
WHERE room_id = ? AND agent_id = ?`)
|
|
1184
|
-
.run(roomId, agentId);
|
|
1185
|
-
});
|
|
1186
|
-
tx.immediate();
|
|
1187
|
-
}
|
|
1188
|
-
/**
|
|
1189
|
-
* Refresh activity for one room captured by a cross-room operation, WITHOUT
|
|
1190
|
-
* rejoining it: a room this persona explicitly left stays left. Returns false
|
|
1191
|
-
* when there was no present membership to refresh.
|
|
1192
|
-
*/
|
|
1193
|
-
touchJoinedRoom(roomId, agentId, epoch) {
|
|
1194
|
-
const tx = this.db.transaction(() => {
|
|
1195
|
-
this.requireEpoch(agentId, epoch);
|
|
1196
|
-
const info = this.db
|
|
1197
1189
|
.prepare(`UPDATE memberships SET last_seen = datetime('now')
|
|
1198
1190
|
WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
|
|
1199
1191
|
.run(roomId, agentId);
|
|
1200
|
-
return info.changes > 0;
|
|
1201
1192
|
});
|
|
1202
|
-
|
|
1193
|
+
tx.immediate();
|
|
1203
1194
|
}
|
|
1204
1195
|
/**
|
|
1205
1196
|
* Open an in-turn wait lease: this persona has a blocking catch_up pending in
|
|
@@ -1216,10 +1207,7 @@ export class ChatStore {
|
|
|
1216
1207
|
beginWaitLease(roomId, agentId, epoch, ttlSeconds) {
|
|
1217
1208
|
const tx = this.db.transaction(() => {
|
|
1218
1209
|
this.requireEpoch(agentId, epoch);
|
|
1219
|
-
|
|
1220
|
-
// A lease ADVERTISES this persona as watching the room. An absent
|
|
1221
|
-
// persona advertising a live watch is the exact contradiction the
|
|
1222
|
-
// present-membership rule exists to remove.
|
|
1210
|
+
// A lease must not advertise an absent persona as watching.
|
|
1223
1211
|
this.requirePresent(roomId, agentId);
|
|
1224
1212
|
this.db
|
|
1225
1213
|
.prepare("DELETE FROM wait_leases WHERE room_id = ? AND expires_at <= datetime('now')")
|
|
@@ -1450,9 +1438,8 @@ export class ChatStore {
|
|
|
1450
1438
|
* the guard). The reject baseline is the TOKEN, unlike the accept path's
|
|
1451
1439
|
* cursor-relative crossed. opts.crossedPreviewChars additionally returns
|
|
1452
1440
|
* crossed previews on an ACCEPTED post. A post never consumes an unseen peer
|
|
1453
|
-
* message. After an accepted post, the
|
|
1454
|
-
* the
|
|
1455
|
-
* recurring probes do not rescan that suffix.
|
|
1441
|
+
* message. After an accepted post, the persona cursor is normalized through
|
|
1442
|
+
* the new own row when it is already at the proven safe peer floor.
|
|
1456
1443
|
*/
|
|
1457
1444
|
postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq, epoch, opts = {}) {
|
|
1458
1445
|
// Reject unstorable text BEFORE the transaction: a body with an embedded
|
|
@@ -1504,12 +1491,8 @@ export class ChatStore {
|
|
|
1504
1491
|
// told the seq of a message it posted in a previous tenure, and must
|
|
1505
1492
|
// certainly not insert a new one.
|
|
1506
1493
|
this.requireEpoch(agentId, epoch);
|
|
1507
|
-
//
|
|
1508
|
-
|
|
1509
|
-
this.requireRoom(roomId);
|
|
1510
|
-
// Authoring is the loudest form of participation. A persona every peer
|
|
1511
|
-
// has been told is absent must not appear in the transcript.
|
|
1512
|
-
this.requirePresent(roomId, agentId);
|
|
1494
|
+
// Presence, room existence, and the cursor share one membership read.
|
|
1495
|
+
const membership = this.requirePresent(roomId, agentId);
|
|
1513
1496
|
// Lost-response retry: one indexed lookup only when the caller opted in.
|
|
1514
1497
|
// It precedes CAS/reference validation so a committed first attempt is
|
|
1515
1498
|
// recoverable even if room state or a referenced parent later changed.
|
|
@@ -1560,8 +1543,7 @@ export class ChatStore {
|
|
|
1560
1543
|
(!Number.isSafeInteger(ifToken) || ifToken < 0)) {
|
|
1561
1544
|
throw new Error("if_last_read_seq must be a non-negative safe integer");
|
|
1562
1545
|
}
|
|
1563
|
-
const
|
|
1564
|
-
const from = cursor?.last_read_seq ?? 0;
|
|
1546
|
+
const from = membership.last_read_seq;
|
|
1565
1547
|
if (ifToken !== null) {
|
|
1566
1548
|
// A future/wrong-cursor token made the predicate `seq > token` empty
|
|
1567
1549
|
// and silently disabled the CAS while unread messages still existed.
|
|
@@ -2166,7 +2148,6 @@ export class ChatStore {
|
|
|
2166
2148
|
SELECT ${messageCols(DEFAULT_MAX_BYTES)}, d.depth AS depth
|
|
2167
2149
|
FROM descendants d
|
|
2168
2150
|
JOIN messages g ON g.room_id = @room AND g.seq = d.seq
|
|
2169
|
-
LEFT JOIN agents a ON a.id = g.agent_id
|
|
2170
2151
|
LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
|
|
2171
2152
|
ORDER BY d.path
|
|
2172
2153
|
LIMIT @lim`), [{ room: roomId, root: seq, maxDepth, lim: cap + 1 }], Math.max(STUB_ALLOWANCE, remaining));
|
|
@@ -2336,18 +2317,8 @@ export class ChatStore {
|
|
|
2336
2317
|
// marker. A stale runtime whose catch_up committed would consume messages
|
|
2337
2318
|
// the current runtime has not seen and can no longer reach.
|
|
2338
2319
|
this.requireEpoch(agentId, epoch);
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
// Distinguish a real non-member from a room deleted after the caller
|
|
2342
|
-
// resolved it. This extra PK lookup runs only on the error path.
|
|
2343
|
-
this.requireRoom(roomId);
|
|
2344
|
-
throw new Error("not a member of this room");
|
|
2345
|
-
}
|
|
2346
|
-
// Consuming messages while absent is invisible consumption: peers see a
|
|
2347
|
-
// left persona and cannot tell their traffic is being read. read_history
|
|
2348
|
-
// stays open for exactly this reason -- it consumes nothing.
|
|
2349
|
-
this.requirePresent(roomId, agentId);
|
|
2350
|
-
const from = cursor.last_read_seq;
|
|
2320
|
+
// Advancing reads require presence; read_history remains non-advancing.
|
|
2321
|
+
const from = this.requirePresent(roomId, agentId).last_read_seq;
|
|
2351
2322
|
const priorityOnly = unreadSummary?.priorityOnly === true;
|
|
2352
2323
|
// Captured under the same IMMEDIATE snapshot as the filtered scan. Own
|
|
2353
2324
|
// rows count toward the cutoff but never toward skipped/remaining.
|
|
@@ -2630,22 +2601,14 @@ export class ChatStore {
|
|
|
2630
2601
|
markRead(roomId, agentId, epoch, seq) {
|
|
2631
2602
|
const tx = this.db.transaction(() => {
|
|
2632
2603
|
this.requireEpoch(agentId, epoch);
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
// Same ordering rule as claimResource: name a deleted room as deleted
|
|
2636
|
-
// rather than reporting its vanished membership as a non-membership.
|
|
2637
|
-
this.requireRoom(roomId);
|
|
2638
|
-
throw new Error("not a member of this room");
|
|
2639
|
-
}
|
|
2640
|
-
// Advancing a marker is participation: it consumes messages peers can
|
|
2641
|
-
// see you have not read.
|
|
2642
|
-
this.requirePresent(roomId, agentId);
|
|
2604
|
+
// Advancing a marker requires presence and reports the prior cursor.
|
|
2605
|
+
const previous = this.requirePresent(roomId, agentId).last_read_seq;
|
|
2643
2606
|
const { latest } = this.db
|
|
2644
2607
|
.prepare("SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?")
|
|
2645
2608
|
.get(roomId);
|
|
2646
2609
|
const target = seq === undefined ? latest : Math.max(0, Math.min(seq, latest));
|
|
2647
2610
|
this.setCursor(roomId, agentId, target);
|
|
2648
|
-
return { previous
|
|
2611
|
+
return { previous, new: target, latest };
|
|
2649
2612
|
});
|
|
2650
2613
|
return tx.immediate();
|
|
2651
2614
|
}
|
|
@@ -2673,7 +2636,6 @@ export class ChatStore {
|
|
|
2673
2636
|
const { rows, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(DEFAULT_MAX_BYTES)}
|
|
2674
2637
|
FROM messages_fts f
|
|
2675
2638
|
JOIN messages g ON g.id = f.rowid
|
|
2676
|
-
LEFT JOIN agents a ON a.id = g.agent_id
|
|
2677
2639
|
LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
|
|
2678
2640
|
WHERE f.body MATCH ? AND g.room_id = ?
|
|
2679
2641
|
ORDER BY rank, g.id LIMIT ? OFFSET ?`), [query, roomId, limit + 1, off], DEFAULT_MAX_BYTES);
|
|
@@ -2703,8 +2665,8 @@ export class ChatStore {
|
|
|
2703
2665
|
keepLast = Math.max(1, Math.floor(keepLast));
|
|
2704
2666
|
const tx = this.db.transaction(() => {
|
|
2705
2667
|
this.requireEpoch(agentId, epoch);
|
|
2706
|
-
//
|
|
2707
|
-
this.
|
|
2668
|
+
// Pruning requires presence, including when force skips unread checks.
|
|
2669
|
+
this.requirePresent(roomId, agentId);
|
|
2708
2670
|
const { c: total } = this.db
|
|
2709
2671
|
.prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
|
|
2710
2672
|
.get(roomId);
|
|
@@ -2803,14 +2765,7 @@ export class ChatStore {
|
|
|
2803
2765
|
assertMaxLen(note, "claim note", 2000);
|
|
2804
2766
|
const tx = this.db.transaction(() => {
|
|
2805
2767
|
this.requireEpoch(agentId, epoch);
|
|
2806
|
-
//
|
|
2807
|
-
// raw FK error from the claims INSERT. BEFORE the presence check, because
|
|
2808
|
-
// a delete cascades the membership away and "you are not a member" is a
|
|
2809
|
-
// false diagnosis of a room that no longer exists.
|
|
2810
|
-
this.requireRoom(roomId);
|
|
2811
|
-
// Taking (or renewing) a claim asserts coordination inside a room this
|
|
2812
|
-
// persona is not in. release_claim stays open: dropping a claim you
|
|
2813
|
-
// already hold is cleanup, not participation.
|
|
2768
|
+
// Taking a claim requires presence; releasing one remains cleanup.
|
|
2814
2769
|
this.requirePresent(roomId, agentId);
|
|
2815
2770
|
const row = this.db
|
|
2816
2771
|
.prepare(`SELECT agent_id, note,
|
package/dist/index.js
CHANGED
|
@@ -143,7 +143,7 @@ PERSONAS AND RUNTIMES
|
|
|
143
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.
|
|
144
144
|
|
|
145
145
|
BACKGROUND POLLER
|
|
146
|
-
- Run the command join_room/resume_persona/wait_for_messages return as a BACKGROUND task. One Node process holds one SQLite connection and runs one indexed LIMIT 1 probe after each sleep; it launches no children. Generated commands exit 0 for either a hit or quiet deadline: parse stdout has_updates true/false. Direct CLI calls without --ok-on-timeout retain exit 124 on timeout. Exit 2 is an error or
|
|
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, equivalent watcher, stale_binding, left_room, left_all_rooms, or no_room_memberships; inspect stderr before re-arming. Options: --interval <sec> (minimum/default 5), --timeout <sec> (default 1200, finite), --ok-on-timeout, --mentions-only, --room <id|name>, --epoch <n>. Your own posts never wake it.
|
|
147
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.
|
|
148
148
|
|
|
149
149
|
RETENTION
|
|
@@ -327,6 +327,7 @@ function abortableSleep(ms, signal) {
|
|
|
327
327
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
328
328
|
});
|
|
329
329
|
}
|
|
330
|
+
/** Resolve the active room and return its name from the same lookup. */
|
|
330
331
|
function requireActive() {
|
|
331
332
|
const { agentId, epoch } = requirePersona();
|
|
332
333
|
if (session.roomId === null) {
|
|
@@ -335,41 +336,37 @@ function requireActive() {
|
|
|
335
336
|
// The room may have been deleted by another server process; the local session
|
|
336
337
|
// would otherwise stay pointed at it and fail later with a low-level DB error.
|
|
337
338
|
// The identity survives: only the active room is gone.
|
|
338
|
-
|
|
339
|
+
const active = store.getRoom(session.roomId);
|
|
340
|
+
if (!active) {
|
|
339
341
|
const stale = session.roomId;
|
|
340
342
|
session.roomId = null;
|
|
341
|
-
throw new Error(`active room ${stale} no longer exists (deleted);
|
|
343
|
+
throw new Error(`active room ${stale} no longer exists (it was deleted); it cannot be ` +
|
|
344
|
+
"rejoined -- use list_rooms to see what remains, or create_room to " +
|
|
345
|
+
"make a new one");
|
|
342
346
|
}
|
|
343
|
-
return { agentId, epoch, roomId: session.roomId };
|
|
347
|
+
return { agentId, epoch, roomId: session.roomId, roomName: active.name };
|
|
344
348
|
}
|
|
345
|
-
/** Resolve an
|
|
346
|
-
*
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (room === undefined) {
|
|
351
|
-
const { agentId, epoch, roomId } = requireActive();
|
|
352
|
-
return {
|
|
353
|
-
agentId,
|
|
354
|
-
epoch,
|
|
355
|
-
roomId,
|
|
356
|
-
roomName: store.getRoom(roomId)?.name ?? null,
|
|
357
|
-
};
|
|
358
|
-
}
|
|
349
|
+
/** Resolve an explicit or active room. Present-only callers leave membership
|
|
350
|
+
* enforcement to the store transaction. */
|
|
351
|
+
function resolveTargetRoom(room) {
|
|
352
|
+
if (room === undefined)
|
|
353
|
+
return requireActive();
|
|
359
354
|
const { agentId, epoch } = requirePersona();
|
|
360
355
|
const target = store.resolveRoom(room);
|
|
361
356
|
if (!target) {
|
|
362
357
|
throw new Error(`no room "${room}". Use list_rooms to see options.`);
|
|
363
358
|
}
|
|
364
|
-
|
|
365
|
-
|
|
359
|
+
return { agentId, epoch, roomId: target.id, roomName: target.name };
|
|
360
|
+
}
|
|
361
|
+
/** Resolve a room for operations that remain available after soft-leave.
|
|
362
|
+
* Membership must exist, but left_at may be set. */
|
|
363
|
+
function resolveJoinedRoom(room) {
|
|
364
|
+
const resolved = resolveTargetRoom(room);
|
|
365
|
+
if (room !== undefined &&
|
|
366
|
+
!store.getMembership(resolved.roomId, resolved.agentId)) {
|
|
367
|
+
throw new Error(`you have never joined room "${resolved.roomName}"; join_room it first`);
|
|
366
368
|
}
|
|
367
|
-
return
|
|
368
|
-
agentId,
|
|
369
|
-
epoch,
|
|
370
|
-
roomId: target.id,
|
|
371
|
-
roomName: target.name,
|
|
372
|
-
};
|
|
369
|
+
return resolved;
|
|
373
370
|
}
|
|
374
371
|
/**
|
|
375
372
|
* Mark the bound persona alive in its active room on tool invocations.
|
|
@@ -428,9 +425,8 @@ function touchCapturedRoom(roomId, agentId, epoch) {
|
|
|
428
425
|
}
|
|
429
426
|
capturedTouchMs.set(key, now);
|
|
430
427
|
try {
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
store.touchJoinedRoom(roomId, agentId, epoch);
|
|
428
|
+
// touch refreshes only a present membership and cannot rejoin a room.
|
|
429
|
+
store.touch(roomId, agentId, epoch);
|
|
434
430
|
}
|
|
435
431
|
catch {
|
|
436
432
|
// Best-effort heartbeat, matching touchSession().
|
|
@@ -1054,7 +1050,8 @@ server.registerTool("join_room", {
|
|
|
1054
1050
|
// The room was deleted by another process between our join and this
|
|
1055
1051
|
// read; same recovery contract as requireActive.
|
|
1056
1052
|
session.roomId = null;
|
|
1057
|
-
return fail(`room "${target.name}" was deleted while joining;
|
|
1053
|
+
return fail(`room "${target.name}" was deleted while joining; use list_rooms ` +
|
|
1054
|
+
"to see what remains, or create_room to make a new one");
|
|
1058
1055
|
}
|
|
1059
1056
|
const persona = store.getPersona(agentId);
|
|
1060
1057
|
return ok({
|
|
@@ -1119,7 +1116,7 @@ server.registerTool("set_role", {
|
|
|
1119
1116
|
}, async ({ role, room }) => {
|
|
1120
1117
|
try {
|
|
1121
1118
|
touchSession();
|
|
1122
|
-
const { agentId, epoch, roomId, roomName } =
|
|
1119
|
+
const { agentId, epoch, roomId, roomName } = resolveTargetRoom(room);
|
|
1123
1120
|
store.setRole(roomId, agentId, epoch, role);
|
|
1124
1121
|
return ok({
|
|
1125
1122
|
agent_id: agentId,
|
|
@@ -1199,7 +1196,8 @@ server.registerTool("whoami", {
|
|
|
1199
1196
|
return ok({
|
|
1200
1197
|
...identity,
|
|
1201
1198
|
joined: false,
|
|
1202
|
-
note: "active room was deleted;
|
|
1199
|
+
note: "active room was deleted; use list_rooms to see what remains, " +
|
|
1200
|
+
"or create_room to make a new one",
|
|
1203
1201
|
});
|
|
1204
1202
|
}
|
|
1205
1203
|
const cur = store.getCursor(session.roomId, agentId);
|
|
@@ -1399,33 +1397,13 @@ server.registerTool("post_message", {
|
|
|
1399
1397
|
return fail("pass either room (explicit target) or expected_room (active-room " +
|
|
1400
1398
|
"assertion), not both");
|
|
1401
1399
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
const target = store.resolveRoom(room);
|
|
1410
|
-
if (!target) {
|
|
1411
|
-
return fail(`no room "${room}". Use list_rooms to see options.`);
|
|
1412
|
-
}
|
|
1413
|
-
if (!store.getMembership(target.id, agentId)) {
|
|
1414
|
-
return fail(`you have never joined room "${target.name}"; join_room it before posting there`);
|
|
1415
|
-
}
|
|
1416
|
-
roomId = target.id;
|
|
1417
|
-
roomName = target.name;
|
|
1418
|
-
}
|
|
1419
|
-
else {
|
|
1420
|
-
({ agentId, epoch, roomId } = requireActive());
|
|
1421
|
-
roomName = store.getRoom(roomId)?.name ?? null;
|
|
1422
|
-
if (expected_room !== undefined) {
|
|
1423
|
-
const expect = store.resolveRoom(expected_room);
|
|
1424
|
-
if (!expect || expect.id !== roomId) {
|
|
1425
|
-
return fail(`expected_room "${expected_room}" does not match the active room ` +
|
|
1426
|
-
`(${roomId}${roomName ? ` "${roomName}"` : ""}); nothing was posted. ` +
|
|
1427
|
-
"Pass room: to target a specific room, or join_room it first.");
|
|
1428
|
-
}
|
|
1400
|
+
const { agentId, epoch, roomId, roomName } = resolveTargetRoom(room);
|
|
1401
|
+
if (room === undefined && expected_room !== undefined) {
|
|
1402
|
+
const expect = store.resolveRoom(expected_room);
|
|
1403
|
+
if (!expect || expect.id !== roomId) {
|
|
1404
|
+
return fail(`expected_room "${expected_room}" does not match the active room ` +
|
|
1405
|
+
`(${roomId} "${roomName}"); nothing was posted. ` +
|
|
1406
|
+
"Pass room: to target a specific room, or join_room it first.");
|
|
1429
1407
|
}
|
|
1430
1408
|
}
|
|
1431
1409
|
touchCapturedRoom(roomId, agentId, epoch);
|
|
@@ -1650,30 +1628,8 @@ server.registerTool("catch_up", {
|
|
|
1650
1628
|
// dispatch can mutate `session` (the persona binding, the active room)
|
|
1651
1629
|
// while a wait sleeps, so everything below runs off these captured
|
|
1652
1630
|
// values.
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
let roomId;
|
|
1656
|
-
let roomName;
|
|
1657
|
-
if (room !== undefined) {
|
|
1658
|
-
// Cross-room read: the ACTIVE room stays untouched. Requires an
|
|
1659
|
-
// existing membership (a never-joined room has no read position to
|
|
1660
|
-
// advance); a soft-left room stays readable -- naming it is the intent
|
|
1661
|
-
// to read it (parity with the scoped poller watch).
|
|
1662
|
-
({ agentId, epoch } = requirePersona());
|
|
1663
|
-
const target = store.resolveRoom(room);
|
|
1664
|
-
if (!target) {
|
|
1665
|
-
return fail(`no room "${room}". Use list_rooms to see options.`);
|
|
1666
|
-
}
|
|
1667
|
-
if (!store.getMembership(target.id, agentId)) {
|
|
1668
|
-
return fail(`you have never joined room "${target.name}", so there is no read position to advance; join_room it first`);
|
|
1669
|
-
}
|
|
1670
|
-
roomId = target.id;
|
|
1671
|
-
roomName = target.name;
|
|
1672
|
-
}
|
|
1673
|
-
else {
|
|
1674
|
-
({ agentId, epoch, roomId } = requireActive());
|
|
1675
|
-
roomName = store.getRoom(roomId)?.name ?? null;
|
|
1676
|
-
}
|
|
1631
|
+
// An explicit room leaves the active room unchanged.
|
|
1632
|
+
const { agentId, epoch, roomId, roomName } = resolveTargetRoom(room);
|
|
1677
1633
|
touchCapturedRoom(roomId, agentId, epoch);
|
|
1678
1634
|
const signal = extra?.signal;
|
|
1679
1635
|
// max_bytes bounds the COMPLETE JSON text returned to the MCP client,
|
|
@@ -1725,7 +1681,7 @@ server.registerTool("catch_up", {
|
|
|
1725
1681
|
if (session.roomId === roomId && session.agentId === agentId) {
|
|
1726
1682
|
session.roomId = null;
|
|
1727
1683
|
}
|
|
1728
|
-
return fail(`room "${roomName
|
|
1684
|
+
return fail(`room "${roomName}" was deleted while ${duringWait ? "waiting" : "reading"}; nothing was read. list_rooms shows what still exists.`);
|
|
1729
1685
|
};
|
|
1730
1686
|
// Abort boundary rule for everything below: once an abort has been
|
|
1731
1687
|
// observed, NO advancing transaction may run.
|
|
@@ -2035,10 +1991,17 @@ server.registerTool("wait_for_messages", {
|
|
|
2035
1991
|
}
|
|
2036
1992
|
roomArg = String(target.id);
|
|
2037
1993
|
}
|
|
2038
|
-
else
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
1994
|
+
else {
|
|
1995
|
+
const memberships = store.roomMembershipCounts(agentId);
|
|
1996
|
+
if (memberships.total === 0) {
|
|
1997
|
+
return fail("no_room_memberships: you have not joined any existing room, so " +
|
|
1998
|
+
"there is nothing to watch; use list_rooms and join_room first");
|
|
1999
|
+
}
|
|
2000
|
+
if (memberships.present === 0) {
|
|
2001
|
+
return fail("left_all_rooms: you have LEFT every room you joined, so there is " +
|
|
2002
|
+
"nothing to watch; call join_room to rejoin one -- its read " +
|
|
2003
|
+
"position and role are preserved");
|
|
2004
|
+
}
|
|
2042
2005
|
}
|
|
2043
2006
|
const command = pollerCmd(agentId, {
|
|
2044
2007
|
room: roomArg,
|
|
@@ -2052,13 +2015,14 @@ server.registerTool("wait_for_messages", {
|
|
|
2052
2015
|
run_as: "background process (do not wait for it inline)",
|
|
2053
2016
|
how_to: "Run `command` in the background. On exit 0, parse stdout: " +
|
|
2054
2017
|
"has_updates:true names the room to catch_up; has_updates:false is a " +
|
|
2055
|
-
"normal quiet deadline. Exit 2 is an error
|
|
2056
|
-
"
|
|
2057
|
-
"wakes YOU depends on the harness.",
|
|
2018
|
+
"normal quiet deadline. Exit 2 is an error, duplicate watcher, or a " +
|
|
2019
|
+
"terminal room/binding state; inspect stderr before re-arming. The " +
|
|
2020
|
+
"exit is only an OS signal; whether it wakes YOU depends on the harness.",
|
|
2058
2021
|
exit_codes: {
|
|
2059
2022
|
"0": "normal completion; inspect stdout has_updates",
|
|
2060
2023
|
"124": "quiet timeout only for direct CLI without --ok-on-timeout",
|
|
2061
|
-
"2": "error
|
|
2024
|
+
"2": "error, equivalent watcher, stale_binding, left_room, " +
|
|
2025
|
+
"left_all_rooms, or no_room_memberships; inspect stderr",
|
|
2062
2026
|
},
|
|
2063
2027
|
baselined: false,
|
|
2064
2028
|
single_process: true,
|
|
@@ -2187,7 +2151,7 @@ server.registerTool("get_message", {
|
|
|
2187
2151
|
touchCapturedRoom(roomId, agentId, epoch);
|
|
2188
2152
|
const msg = store.getMessage(roomId, seq, offset ?? 0, max_chars);
|
|
2189
2153
|
if (!msg) {
|
|
2190
|
-
return fail(`no message ${seq} in room "${roomName
|
|
2154
|
+
return fail(`no message ${seq} in room "${roomName}"`);
|
|
2191
2155
|
}
|
|
2192
2156
|
return ok({ ...lossDisclosure(agentId, epoch), ...msg });
|
|
2193
2157
|
}
|
|
@@ -2234,7 +2198,7 @@ server.registerTool("get_thread", {
|
|
|
2234
2198
|
touchCapturedRoom(roomId, agentId, epoch);
|
|
2235
2199
|
const thread = store.getThread(roomId, seq, max_depth ?? 3, preview_chars);
|
|
2236
2200
|
if (!thread) {
|
|
2237
|
-
return fail(`no message ${seq} in room "${roomName
|
|
2201
|
+
return fail(`no message ${seq} in room "${roomName}"`);
|
|
2238
2202
|
}
|
|
2239
2203
|
return ok({ ...lossDisclosure(agentId, epoch), ...thread });
|
|
2240
2204
|
}
|
|
@@ -2339,7 +2303,7 @@ server.registerTool("claim", {
|
|
|
2339
2303
|
}, async ({ room, key, ttl_seconds, note }) => {
|
|
2340
2304
|
try {
|
|
2341
2305
|
touchSession();
|
|
2342
|
-
const { agentId, epoch, roomId, roomName } =
|
|
2306
|
+
const { agentId, epoch, roomId, roomName } = resolveTargetRoom(room);
|
|
2343
2307
|
touchCapturedRoom(roomId, agentId, epoch);
|
|
2344
2308
|
return ok({
|
|
2345
2309
|
room_id: roomId,
|
package/dist/poller.js
CHANGED
|
@@ -18,7 +18,11 @@ The interval must be 5..3600 seconds (default 5). The timeout must be
|
|
|
18
18
|
1..86400 seconds (default 1200). Exit 0 = work exists; with --ok-on-timeout,
|
|
19
19
|
exit 0 also reports a quiet deadline as has_updates:false. Without it,
|
|
20
20
|
timeout exits 124. Exit 2 = invalid arguments, duplicate watcher, DB error, or
|
|
21
|
-
a
|
|
21
|
+
a terminal watcher state. Inspect stderr before re-arming:
|
|
22
|
+
stale_binding persona resumed by a newer runtime
|
|
23
|
+
left_room persona left the scoped room
|
|
24
|
+
left_all_rooms persona left every joined room
|
|
25
|
+
no_room_memberships persona has no remaining room memberships
|
|
22
26
|
|
|
23
27
|
--epoch binds this watcher to one runtime tenure of the persona. Every probe
|
|
24
28
|
reads the persona's current epoch; once it moves, this watcher is speaking for
|
|
@@ -303,13 +307,8 @@ try {
|
|
|
303
307
|
/**
|
|
304
308
|
* A watcher bound to an epoch stops the moment the persona moves on.
|
|
305
309
|
*
|
|
306
|
-
* The epoch is
|
|
307
|
-
*
|
|
308
|
-
* row very differently: the scoped one reports "the room was deleted or the
|
|
309
|
-
* membership disappeared", which is a false and actively misleading
|
|
310
|
-
* diagnosis, while the all-rooms one reads it as an ordinary quiet interval
|
|
311
|
-
* and never exits at all. Comparing in JS keeps the true reason attached to
|
|
312
|
-
* the exit.
|
|
310
|
+
* The epoch is returned as data, never filtered in SQL, so a takeover remains
|
|
311
|
+
* distinguishable from room and membership state changes.
|
|
313
312
|
*/
|
|
314
313
|
const checkEpoch = (current) => {
|
|
315
314
|
if (args.epoch === undefined)
|
|
@@ -389,18 +388,32 @@ try {
|
|
|
389
388
|
};
|
|
390
389
|
}
|
|
391
390
|
else {
|
|
392
|
-
const
|
|
393
|
-
.prepare(
|
|
391
|
+
const membershipCounts = database
|
|
392
|
+
.prepare(`SELECT COUNT(*) AS total,
|
|
393
|
+
COALESCE(SUM(CASE WHEN left_at IS NULL THEN 1 ELSE 0 END), 0)
|
|
394
|
+
AS present
|
|
395
|
+
FROM memberships WHERE agent_id = ?`)
|
|
394
396
|
.get(args.agent);
|
|
395
|
-
if (
|
|
396
|
-
argumentError(`agent "${args.agent}"
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
397
|
+
if (membershipCounts.total === 0) {
|
|
398
|
+
argumentError(`no_room_memberships: agent "${args.agent}" has no room memberships; ` +
|
|
399
|
+
"join a room and generate a fresh poller command");
|
|
400
|
+
}
|
|
401
|
+
if (membershipCounts.present === 0) {
|
|
402
|
+
argumentError(`left_all_rooms: agent "${args.agent}" has left every joined room; ` +
|
|
403
|
+
"rejoin one with join_room and use the new poller command");
|
|
404
|
+
}
|
|
405
|
+
// Anchor on the persona so every quiet probe still returns epoch and
|
|
406
|
+
// membership state. A vanished persona yields no row.
|
|
403
407
|
const statement = database.prepare(`SELECT a.runtime_epoch AS current_epoch,
|
|
408
|
+
EXISTS (
|
|
409
|
+
SELECT 1 FROM memberships all_mb
|
|
410
|
+
WHERE all_mb.agent_id = @agent
|
|
411
|
+
) AS has_membership,
|
|
412
|
+
EXISTS (
|
|
413
|
+
SELECT 1 FROM memberships present_mb
|
|
414
|
+
WHERE present_mb.agent_id = @agent
|
|
415
|
+
AND present_mb.left_at IS NULL
|
|
416
|
+
) AS has_present,
|
|
404
417
|
(SELECT mb.room_id FROM memberships mb
|
|
405
418
|
WHERE mb.agent_id = @agent AND mb.left_at IS NULL
|
|
406
419
|
AND EXISTS (
|
|
@@ -415,8 +428,21 @@ try {
|
|
|
415
428
|
const roomName = database.prepare("SELECT name FROM rooms WHERE id = ?");
|
|
416
429
|
probe = () => {
|
|
417
430
|
const row = statement.get(params);
|
|
418
|
-
|
|
419
|
-
|
|
431
|
+
if (!row) {
|
|
432
|
+
checkEpoch(null);
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
checkEpoch(row.current_epoch);
|
|
436
|
+
if (!row.has_membership) {
|
|
437
|
+
argumentError(`no_room_memberships: agent "${args.agent}" has no remaining room ` +
|
|
438
|
+
"memberships; join a room and generate a fresh poller command");
|
|
439
|
+
}
|
|
440
|
+
if (!row.has_present) {
|
|
441
|
+
argumentError(`left_all_rooms: agent "${args.agent}" left every joined room while ` +
|
|
442
|
+
"this watcher was armed; rejoin one with join_room and use the new " +
|
|
443
|
+
"poller command");
|
|
444
|
+
}
|
|
445
|
+
if (row.room_id === null)
|
|
420
446
|
return undefined;
|
|
421
447
|
// Name lookup only on a HIT, so the quiet path stays one statement.
|
|
422
448
|
const named = roomName.get(row.room_id);
|
package/package.json
CHANGED