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 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 one
198
- of two diagnostics that both mean *do not re-arm this command*:
199
- `stale_binding` (the persona was resumed elsewhere, so call `resume_persona`
200
- and use the command it returns) and `left_room` (this persona left the
201
- watched room, so `join_room` again first).
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
- `npm run web` serves a lightweight viewer at `http://localhost:8787` (override
230
- with `AGENT_CHAT_VIEWER_PORT`). Watch the rooms your agents are using, or join
231
- and post into them yourself.
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, Gemini-family). Existing
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
 
@@ -1 +1 @@
1
- {"version":"0.13.0","commit":"1598f9d","built_at":"2026-07-27T02:50:39.453Z","artifact_hash":"2b77819c838d4536635e18740add7c598ada470be537c8cfe11db322222c461c"}
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
- return v;
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).trim();
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: unread relative to each present membership's marker.
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
- // Rooms count distinguishes a doomed watch (persona in no room -> fail)
153
- // from a live one.
154
- const { n: rooms } = db
155
- .prepare("SELECT COUNT(*) AS n FROM memberships WHERE agent_id = ? AND left_at IS NULL")
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 (rooms === 0)
158
- return null;
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 === null)
218
- fail(`agent "${agent}" is not a member of any room`);
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
- // One DEFERRED transaction = one snapshot for baseline + counts + latest.
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 null;
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 { baseline, unread, unreadMentions, latest };
314
+ return {
315
+ state: "ok",
316
+ roomId,
317
+ baseline,
318
+ unread,
319
+ unreadMentions,
320
+ latest,
321
+ };
291
322
  })
292
323
  .deferred();
293
- if (snap === null) {
294
- fail(`agent "${args.agent}" is not a member of room ${roomId}; join first or pass --since`);
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
- // 0 rows = the room vanished under us; report it instead of a false
760
- // success the caller would trust.
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} no longer exists (deleted); rejoin with join_room`);
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
- /** Throw a clean, recoverable error when the room no longer exists. Called
771
- * INSIDE write transactions whose room reference was resolved earlier, so
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(`room ${roomId} no longer exists (deleted); rejoin with join_room`);
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
- /** How many rooms an identity is currently present in (for wait_for_messages
883
- * to refuse a doomed all-rooms watch when the agent is in none). */
884
- presentRoomCount(agentId) {
885
- const { c } = this.db
886
- .prepare("SELECT COUNT(*) AS c FROM memberships WHERE agent_id = ? AND left_at IS NULL")
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
- * Verify that this persona is PRESENT in the room, throwing if it has left.
942
- *
943
- * Like requireEpoch, this MUST run inside the transaction that performs the
944
- * guarded write. Checking first and writing afterwards leaves a leave_room
945
- * committing in the gap, and the write then lands from a persona every peer
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("SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
955
+ .prepare(`SELECT last_read_seq, left_at FROM memberships
956
+ WHERE room_id = ? AND agent_id = ?`)
960
957
  .get(roomId, agentId);
961
- if (!row) {
962
- throw new Error(`you are not a member of room ${roomId}; join_room it first`);
958
+ if (row !== undefined && row.left_at === null) {
959
+ return { last_read_seq: row.last_read_seq };
963
960
  }
964
- if (row.left_at !== null) {
965
- throw new Error(`you have LEFT room ${roomId}, so you cannot participate in it: ` +
966
- `peers see you as absent. Call join_room to rejoin -- your read ` +
967
- `position and room-local role are preserved.`);
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.requireRoom(roomId);
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(`you are not a member of room ${roomId}; join_room it first`);
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, clearing that room's left_at (an actively
1168
- * acting runtime re-asserts presence there).
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
- return tx.immediate();
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
- this.requireRoom(roomId);
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 posting cursor and sibling cursors at
1454
- * the proven safe peer floor are normalized through the new own row so their
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
- // The caller's room reference predates this transaction; a concurrent
1508
- // delete_room otherwise surfaces as a raw FK failure on the INSERT.
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 cursor = this.getCursor(roomId, agentId);
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
- const cursor = this.getCursor(roomId, agentId);
2340
- if (!cursor) {
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
- const cursor = this.getCursor(roomId, agentId);
2634
- if (!cursor) {
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: cursor.last_read_seq, new: target, latest };
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
- // A deleted room must not report a successful no-op prune.
2707
- this.requireRoom(roomId);
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
- // Same deleted-room window as postMessage: fail cleanly, not with a
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 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.
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
- if (!store.getRoom(session.roomId)) {
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); rejoin with join_room`);
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 optional explicit joined room without changing the active room.
346
- * Explicit operations require an established identity and an existing
347
- * membership, but a soft-left membership remains addressable: naming the room
348
- * is deliberate and may be needed to inspect history or release old claims. */
349
- function resolveJoinedRoom(room) {
350
- if (room === undefined) {
351
- const { agentId, epoch, roomId } = requireActive();
352
- return {
353
- agentId,
354
- epoch,
355
- roomId,
356
- roomName: store.getRoom(roomId)?.name ?? null,
357
- };
358
- }
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
- if (!store.getMembership(target.id, agentId)) {
365
- throw new Error(`you have never joined room "${target.name}"; join_room it first`);
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
- // 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);
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; rejoin with join_room`);
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 } = resolveJoinedRoom(room);
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; rejoin",
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
- let agentId;
1403
- let epoch;
1404
- let roomId;
1405
- let roomName;
1406
- if (room !== undefined) {
1407
- // Explicit target: same membership rule as catch_up({room}).
1408
- ({ agentId, epoch } = requirePersona());
1409
- const target = store.resolveRoom(room);
1410
- if (!target) {
1411
- return fail(`no room "${room}". Use list_rooms to see options.`);
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
- let agentId;
1654
- let epoch;
1655
- let roomId;
1656
- let roomName;
1657
- if (room !== undefined) {
1658
- // Cross-room read: the ACTIVE room stays untouched. Requires an
1659
- // existing membership (a never-joined room has no read position to
1660
- // advance); a soft-left room stays readable -- naming it is the intent
1661
- // to read it (parity with the scoped poller watch).
1662
- ({ agentId, epoch } = requirePersona());
1663
- const target = store.resolveRoom(room);
1664
- if (!target) {
1665
- return fail(`no room "${room}". Use list_rooms to see options.`);
1666
- }
1667
- if (!store.getMembership(target.id, agentId)) {
1668
- return fail(`you have never joined room "${target.name}", so there is no read position to advance; join_room it first`);
1669
- }
1670
- roomId = target.id;
1671
- roomName = target.name;
1672
- }
1673
- else {
1674
- ({ agentId, epoch, roomId } = requireActive());
1675
- roomName = store.getRoom(roomId)?.name ?? null;
1676
- }
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 ?? roomId}" was deleted while ${duringWait ? "waiting" : "reading"}; nothing was read. list_rooms shows what still exists.`);
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 if (store.presentRoomCount(agentId) === 0) {
2039
- // Unscoped watch of ALL your rooms, but you are in none: the poller
2040
- // would exit 2 immediately. Say so rather than emit a doomed command.
2041
- return fail("you are not present in any room, so there is nothing to watch; join_room first, or pass a room you have joined");
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/duplicate watcher. Re-arm " +
2056
- "only if still needed. The exit is only an OS signal; whether it " +
2057
- "wakes YOU depends on the harness.",
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 or equivalent watcher already running",
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 ?? roomId}"`);
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 ?? roomId}"`);
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 } = resolveJoinedRoom(room);
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 stale binding (the persona was resumed by a newer runtime).
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 carried as returned DATA, never as a WHERE predicate. As a
307
- * predicate it would simply remove the row, and the two probes read a missing
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 present = database
393
- .prepare("SELECT 1 FROM memberships WHERE agent_id = ? AND left_at IS NULL LIMIT 1")
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 (!present)
396
- argumentError(`agent "${args.agent}" is not present in any room`);
397
- // ANCHORED on the persona row, not on memberships. The unread test alone
398
- // returns ZERO rows on a quiet interval -- the normal case -- so there is no
399
- // row to hang an epoch column on. Selecting FROM agents makes the result
400
- // exactly one row whenever the persona exists, carrying the epoch every
401
- // time and the hit only when there is one. A vanished persona yields no row,
402
- // which checkEpoch reads as a dead binding rather than as silence.
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
- checkEpoch(row ? row.current_epoch : null);
419
- if (!row || row.room_id === null)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multi-agent-collaboration-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "MCP server: a shared chat room where AI agents coordinate via a SQLite-backed message log",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {