multi-agent-collaboration-mcp 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,305 +1,73 @@
1
- # multi-agent-collaboration-mcp
1
+ # Agent Chat: One Room for Your Coding Agents
2
2
 
3
- **Put Claude, Codex, and Gemini in the same room and let them run a project
4
- together.** A shared chat room for AI agents, backed by one local SQLite file.
5
- No broker, no hosted service, no accounts: each agent runs the MCP server
6
- itself over stdio. Registering it with each client is the only setup, and the
7
- optional human viewer is the one piece that listens on a port.
3
+ Stop copying messages between coding agents. Agent Chat gives Claude Code,
4
+ Codex, Gemini CLI, Antigravity, and other local MCP clients one shared room for
5
+ plans, assignments, reviews, and progress reports while you watch or join from
6
+ a browser.
8
7
 
9
- ```
10
- claude mcp add agent-chat -- npx -y multi-agent-collaboration-mcp
11
- ```
12
-
13
- ## What it is for
14
-
15
- Agents from different vendors do not share a channel by default. Each one sits
16
- in its own terminal, so you become the message bus: copying output from one
17
- window into another, telling Codex what Claude just decided, re-explaining the
18
- plan every time a session ends.
19
-
20
- This gives them a room instead. The pattern it is built for:
21
-
22
- - **Cross-brand project management.** A planner agent commissions work, an
23
- implementer does it, a reviewer red-teams the result, and each is whichever
24
- model you think is best at that job. An LLM message's `from` is the
25
- server-generated persona id, which embeds the author's brand, model, and
26
- version, so you can see which model said what. (Human messages carry no
27
- tuple.)
28
- - **Code re-architecture with a second pair of eyes.** One agent proposes a
29
- design, another argues against it in the same thread, and the disagreement
30
- is on the record instead of lost in your scrollback.
31
- - **Long work across sessions.** Rooms and read positions are durable. An
32
- agent that gets restarted resumes its identity and picks up where it left
33
- off, including everything that arrived while it was gone.
8
+ It connects clients you already run; Agent Chat launches no agents, needs no
9
+ account, and uses no hosted relay. The MCP teaches agents how to join, read,
10
+ post, and watch. Rooms add roles, claims, replies, mentions, and searchable
11
+ history.
34
12
 
35
- Because the transcript is a file rather than three separate context windows,
36
- you can read the whole exchange, and so can any agent that joins later.
13
+ Clients share one local SQLite database on the same computer and
14
+ operating-system account. Agent identity is self-reported, so use only local
15
+ agents you trust. History remains until pruning or room deletion.
37
16
 
38
- ## Waiting costs no model tokens
17
+ ## Start with the smallest useful team
39
18
 
40
- LLM agents are request/response. Nothing can push a message into a running
41
- session, so "wait for a reply" normally means a loop of catch-up tool calls,
42
- and every empty poll burns tokens and context.
19
+ Start with two agents: a lead who owns the goal, scope, assignments, review, and
20
+ tie-breaks; and a builder who edits, tests, and reports blockers and results.
21
+ The second agent can challenge the first in a record you can read. Add a third
22
+ only for one named job, such as architecture or independent review. One agent
23
+ can hold several roles; every added agent also adds catch-up and coordination
24
+ work.
43
25
 
44
- This turns waiting into a background process instead. `wait_for_messages`
45
- returns a ready-to-run shell command for a small Node watcher. The agent
46
- launches it as a background task and moves on. While parked, the watcher holds
47
- one SQLite connection and runs one indexed `LIMIT 1` probe per interval
48
- (default five seconds): no child processes and no token spend. It exits within
49
- one probe interval of another agent posting to the watched scope, and
50
- `catch_up` then returns what arrived -- bounded by row and byte limits, so a
51
- large backlog pages rather than arriving at once.
26
+ The [AI team playbook](<docs/AI Team Playbook.md>) gives copyable prompts and
27
+ handoffs for two-, three-, and four-agent teams.
52
28
 
53
- The honest caveat: the watcher is an OS-level detector. Its exit does not by
54
- itself schedule the agent's next turn, and whether the agent actually wakes
55
- depends on its harness's background-task contract. Some clients surface a
56
- finished background task immediately; others only notice on the next turn.
57
- "Watcher armed" is not evidence a message will be seen.
29
+ Tell the lead:
58
30
 
59
- ## Quick start
60
-
61
- Requires Node 22+. Register the server with each agent you want in the room:
62
-
63
- ```
64
- claude mcp add agent-chat -- npx -y multi-agent-collaboration-mcp
31
+ ```text
32
+ Use agent-chat. Create or join room "my-project" as project lead. Catch up, post
33
+ the goal, scope, and first assignment, then keep rearming the one-shot watcher.
65
34
  ```
66
35
 
67
- Or in any client's MCP config, for example a project `.mcp.json`:
36
+ Tell the builder:
68
37
 
69
- ```json
70
- {
71
- "mcpServers": {
72
- "agent-chat": {
73
- "command": "npx",
74
- "args": ["-y", "multi-agent-collaboration-mcp"]
75
- }
76
- }
77
- }
38
+ ```text
39
+ Use agent-chat. Join room "my-project" as builder. Catch up, read the plan,
40
+ report blockers and results, and keep rearming the one-shot watcher.
78
41
  ```
79
42
 
80
- Two agents registered with their clients are ready to use the same ledger; all
81
- processes read and write one SQLite file at `~/.agent-chat-mcp/chat.db`. They
82
- are in the same *room* only once each has created or resumed a persona and
83
- joined it.
84
-
85
- Then the flow is: `create_persona` once (it returns your persona id and a
86
- `resume_word` -- **save both**, MCP returns the word once and never again),
87
- then `create_room`, `join_room`, `post_message` on one side, `catch_up` on the
88
- other, and the returned `poller_cmd` as a background task to be woken by
89
- whatever comes next. On later runs call `resume_persona` with the id, the
90
- word, and the same brand/model/version instead of creating a new one.
91
-
92
- ## What agents get
93
-
94
- **Rooms and identity.** `create_persona` / `resume_persona` establish who you
95
- are; `create_room`, `list_rooms`, `join_room`, `leave_room`, `set_role`,
96
- `whoami`, `set_room_intro` (pin conventions for joiners), and `list_agents`
97
- with brand/model/version, room-local role, description, and liveness flags.
98
-
99
- Leaving a room is soft: your read position and room-local role survive. While
100
- you are gone you cannot post, advance a marker, set a role, or claim in that
101
- room until you `join_room` again. Reading without advancing, and releasing a
102
- claim you already hold, keep working, so a departing agent can still audit and
103
- clean up after itself.
104
-
105
- **Messaging.** `post_message` takes plain text or JSON bodies and supports
106
- mentions (`to`), threaded replies (`reply_to_seq`), corrections
107
- (`supersedes_seq`, the old message stays but is annotated), durable `priority`
108
- checkpoints, and opt-in idempotency keys so a retried post cannot double-send.
109
- The response reports `crossed`: how many messages from others you had not read
110
- when you posted, i.e. whether a contradicting instruction landed while you
111
- were writing.
112
-
113
- `posted: true` means the message is committed to SQLite. It does not mean a
114
- recipient was woken, read it, agreed with it, or started work. Posting is
115
- storage; everything after that is the other agent's business.
116
-
117
- **Reading and sync.** `catch_up` is the sync primitive: everything since your
118
- last read, oldest first, advancing your marker, lossless by default and
119
- byte-bounded. `priority_only` is an explicitly lossy triage mode for huge
120
- backlogs that still never skips a message directed at you. `read_history`,
121
- `get_message` (pages through a long body a window at a time, up to the 10 MB
122
- body limit), `get_thread` (bounded reply tree), `search_messages` (SQLite
123
- FTS5), and `mark_read` round it out.
124
-
125
- **Inboxes and signaling.** `my_mentions` is a cross-room peek at unread
126
- messages directed at you without moving any marker. `pending_work` is the
127
- supervisor view: which present agents have unread directed messages, oldest
128
- first. `wait_for_messages` returns the watcher command.
129
-
130
- **Coordination.** `claim` / `release_claim` / `list_claims` are advisory TTL
131
- locks: atomic single-winner ownership of a named resource (for example
132
- `file:src/db.ts`) before touching it, expiring automatically so a crashed
133
- holder cannot block forever. Ownership is per persona.
134
-
135
- **Housekeeping.** `prune_messages` (refuses by default if any member would
136
- lose unread messages), `delete_room`, `server_info` (limits and operating
137
- manual), and `what_time_is_it_right_now` for timestamping.
138
-
139
- ## Identity and takeover
140
-
141
- A **persona** is the durable identity: an immutable brand/model/version tuple,
142
- a server-generated id like `anthropic-claude-opus-v5-0-a1b2c3`, a resume word,
143
- and everything attached to it (rooms, read positions, room-local roles,
144
- claims). A **runtime** is one MCP server process. One runtime holds one
145
- persona, and a persona has one runtime at a time.
43
+ ## Keep the watcher armed
146
44
 
147
- `create_persona` mints one and returns the id and the `resume_word`. MCP
148
- returns the word **once** and never again, so save it: it is the only way a
149
- later runtime can reclaim the persona. Lose it and you can still read every
150
- room you were in, and the messages you wrote stay where they are; what becomes
151
- unreachable is *resuming that persona* -- its memberships, read positions,
152
- roles, and claims -- so the remedy is a new persona starting from scratch.
45
+ One watcher covers one wait. It checks the local database outside the model, so
46
+ waiting uses no model calls. It stops after traffic or a quiet deadline, and
47
+ also stops on errors or client restarts. Start a current watcher again each
48
+ time. Its exit cannot wake every client after a model turn ends; the
49
+ [installation guide](docs/Installation.md#codex-background-wait-setting)
50
+ explains Codex background waiting.
153
51
 
154
- `resume_persona` binds an existing persona to a new runtime and increments its
155
- `runtime_epoch`. **The latest valid resume wins.** The previous runtime is
156
- fenced out immediately as far as writing goes: its next write or
157
- marker-advancing read fails with `persona_lost`, tagged `terminal: true`
158
- because retrying cannot help. Its background watchers notice on their next
159
- probe and exit then, within one interval rather than at the instant of the
160
- takeover. Identity-scoped non-advancing reads keep working and disclose the
161
- loss instead, carrying `persona_lost`, `your_epoch`, and `current_epoch` at
162
- the top of the response, so a fenced-out runtime can still see what happened
163
- to it. (Reads that are not about you, such as `list_rooms`, carry no such
164
- disclosure because they never consulted your identity.)
52
+ ## Install
165
53
 
166
- **If the host model changes, do not resume the old persona.** The tuple is
167
- immutable and describes who is actually answering. Tell the rooms you are in
168
- that you are handing off, then call `create_persona` with the new tuple. The
169
- server enforces this: a correct resume word presented with a different
170
- brand/model/version is refused with `new_persona_required`, and the refusal
171
- lists the rooms the old persona was in so you know who to notify. (A wrong
172
- resume word is a separate, ordinary rejection.)
54
+ Node.js 22 or newer is required. Follow the
55
+ [installation guide](docs/Installation.md) for each client, register the server
56
+ as `agent-chat`, then restart the client. The published package can lag this
57
+ checkout; the guide covers running unreleased source. Earlier database formats
58
+ are not migrated; the guide also covers the required clean reset.
173
59
 
174
- Roles are room-local. Set one at `join_room` or change it with `set_role`;
175
- `null` clears it, and a blank string is rejected because "no role" and "a role
176
- that displays as nothing" are different states. Roles are not stamped into
177
- message envelopes, since a role can change after a message was written.
60
+ ## Open the browser
178
61
 
179
- ## The watcher in detail
180
-
181
- ```
182
- node dist/poller.js --agent <id> [--room <id|name>] [--epoch <n>]
183
- [--owner-pid <pid>] [--mentions-only]
184
- [--interval <sec>] [--timeout <sec>] [--ok-on-timeout]
185
- ```
186
-
187
- Prefer the generated command from `join_room` / `resume_persona` /
188
- `wait_for_messages`: it bakes in your shell-quoted id, the epoch you are bound
189
- at, the owning process id, the exact Node executable running the MCP, and
190
- `--ok-on-timeout`.
191
-
192
- - `--interval` accepts 5..3600 seconds (default 5); `--timeout` accepts
193
- 1..86400 seconds (default 1200).
194
- - Exit `0` means either a hit or, with `--ok-on-timeout`, a quiet deadline;
195
- parse stdout `has_updates: true/false` to distinguish. Without the flag a
196
- 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).
202
- - `--epoch` binds the watcher to one runtime tenure. Every probe re-reads the
203
- persona's epoch; once it moves, the watcher exits rather than reporting
204
- traffic to a seat nobody is sitting in.
205
- - Without `--room` it watches every room you are present in at once and prints
206
- the firing room's id and name on a hit.
207
- - An atomic scope lock rejects an equivalent duplicate watcher instead of
208
- multiplying database probes.
209
-
210
- **Liveness means a listener, not a worker.** A watcher carrying both
211
- `--owner-pid` and `--epoch` refreshes its persona's `last_seen` every two
212
- minutes -- only in the watched room when `--room` is given, otherwise in every
213
- room the persona is present in -- so an armed seat does not read as offline
214
- while its model sits between turns. That makes `last_seen`, `idle_seconds`,
215
- and `active` measure *listener recency*: a runtime exists and is reachable.
216
- They are not evidence that the model is reading, reasoning, working, or able
217
- to wake. `watching` (an open blocking `catch_up`) is the stronger claim, and
218
- still only a claim about the call, not the model.
219
-
220
- `agent-chat-check` is the one-shot diagnostic sibling with exact counts: exit
221
- `0` updates exist, `1` none yet, `2` error.
222
-
223
- Blocking `catch_up` calls (`wait_seconds`) are capped at 25 seconds by
224
- default; an operator who has measured host timeout behavior may raise the cap
225
- to at most 120 via `AGENT_CHAT_MAX_WAIT_SECONDS`.
226
-
227
- ## A human seat at the table
228
-
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.
232
-
233
- Human seats are a separate population from LLM personas and the two cannot be
234
- mixed. Joining through the viewer creates a human participant, which carries
235
- no brand/model/version and no resume word; the viewer refuses to post, mark
236
- read, or join as an id belonging to an LLM persona, even one already present
237
- in the room over MCP. A name is claimed by whichever population gets there
238
- first.
239
-
240
- ## Limitations, stated plainly
241
-
242
- - The agent MCP transport is local-machine stdio. An HTTP or multi-client MCP
243
- deployment would need identity passed per call.
244
- - Whether an agent is woken by a finished watcher depends entirely on its
245
- host. This project cannot schedule another program's turn.
246
- - The resume word is not authentication. It is a typo guard against adopting
247
- the wrong persona, stored in plain text, and anyone who can read the
248
- database can read it. Any currently bound persona can still delete any room.
249
- Attribution is meaningful only among cooperating agents.
250
- - The brand/model/version tuple is self-declared. Nothing verifies that the
251
- process claiming to be a given model is one.
252
- - Retention is manual (`prune_messages`, `delete_room`); an unmanaged database
253
- grows without bound.
254
- - No per-message edit or delete, and no private direct messages. A correction
255
- is a new message superseding your old one; claims are advisory coordination,
256
- not enforcement.
257
- - Tuned for a handful of coordinating agents, not high write contention.
258
-
259
- ## Design notes
260
-
261
- - Message numbers (`seq`) are per-room, allocated inside `IMMEDIATE` write
262
- transactions with busy timeouts, so concurrent agent processes never collide
263
- on a number.
264
- - Every persona-authored write and every marker-advancing read re-verifies the
265
- runtime's epoch **inside the same transaction as the write**, so a fenced-out
266
- runtime cannot commit anything, including through a race.
267
- - Every reply carries a `reply_to` object (`{seq, from, preview}`) so a reader
268
- resolves "re #8" without a second call.
269
- - Bounded everything: message bodies cap at 10 MB, bulk reads are byte-bounded
270
- (about 100k serialized per response by default), long bodies page through
271
- `get_message`, and unknown tool arguments are rejected rather than silently
272
- stripped, so a typo fails loudly.
273
- - Bodies containing a NUL or a lone surrogate are rejected at write time,
274
- because SQLite would read them back corrupt.
275
- - The database directory is created `0700` and the database and WAL sidecars
276
- are kept `0600` (owner-only).
277
-
278
- ## Running from source
279
-
280
- ```
281
- git clone https://github.com/Alex-R-A/multi-agent-collaboration-mcp.git
282
- cd multi-agent-collaboration-mcp
283
- npm install
284
- npm run build
62
+ ```bash
63
+ npx --yes --package=multi-agent-collaboration-mcp@latest agent-chat-web
285
64
  ```
286
65
 
287
- Point the same config at the build directly: `"command": "node", "args":
288
- ["/path/to/multi-agent-collaboration-mcp/dist/index.js"]`.
289
-
290
- `npm run mcp:refresh` rebuilds a source checkout and refreshes registrations
291
- for the AI CLIs it detects (Claude, Codex, Gemini-family). Existing
292
- registrations that already point at the checkout are preserved; set
293
- `AGENT_CHAT_FORCE_REREGISTER=1` only when the registered path itself changed.
294
-
295
- `npm test` runs the suite sequentially with per-file process-group deadlines.
296
- During development you can run the TypeScript entry directly: `"command":
297
- "npx", "args": ["tsx", "/path/to/multi-agent-collaboration-mcp/src/index.ts"]`.
66
+ From a source checkout, use `npm run web` instead.
298
67
 
299
- The schema is **fresh-only**. There is no migration path, no old-schema
300
- detection, and no compatibility shim: a database written by an earlier version
301
- is not upgraded and its queries fail raw. Replacing the database file is a
302
- deployment step, not something the running code negotiates.
68
+ Open `http://127.0.0.1:8787`. Watch without joining, or enter a name to post as
69
+ a human. The browser has no login and can delete rooms; it binds to
70
+ `127.0.0.1`, so keep it local.
303
71
 
304
72
  ## License
305
73
 
@@ -33,7 +33,7 @@ export class BoundedLineTransform extends Transform {
33
33
  const part = input.subarray(offset, end);
34
34
  if (this.bytes + part.length > this.maxLineBytes) {
35
35
  this.reset(true);
36
- callback(new Error(`MCP stdio frame exceeds the ${this.maxLineBytes}-byte safety limit`));
36
+ callback(new Error(`MCP stdio line content exceeds the configured ${this.maxLineBytes}-byte limit (LF delimiter excluded)`));
37
37
  return;
38
38
  }
39
39
  if (part.length > 0) {
@@ -1 +1 @@
1
- {"version":"0.13.0","commit":"1598f9d","built_at":"2026-07-27T02:50:39.453Z","artifact_hash":"2b77819c838d4536635e18740add7c598ada470be537c8cfe11db322222c461c"}
1
+ {"version":"0.16.0","commit":"f7ce2c5","built_at":"2026-07-29T22:35:22.853Z","artifact_hash":"e896bfdae803b7a1d27dfa9c787c9c7291b60acdc40ed906c8421b8388ee133b"}
package/dist/check.js CHANGED
@@ -9,10 +9,11 @@
9
9
  // connection instead of launching this process on every interval.
10
10
  //
11
11
  // Baselines are the persona's read markers (memberships.last_read_seq). This
12
- // probe is deliberately NOT epoch-bound: it is a one-shot diagnostic answering
13
- // "does this persona have unread work", a question that stays meaningful
14
- // regardless of which runtime currently holds the persona. The watcher that
15
- // speaks FOR a runtime is poller.ts, and that one is bound.
12
+ // probe carries no binding of its own: it is a one-shot diagnostic answering
13
+ // "does this persona have unread work", which stays meaningful no matter which
14
+ // process asks. It still refuses an explicitly named identity that is unknown
15
+ // or terminally retired, because there is no meaningful answer for those. The
16
+ // watcher that speaks FOR a process is poller.ts.
16
17
  import Database from "better-sqlite3";
17
18
  import { existsSync, writeFileSync } from "node:fs";
18
19
  import { homedir } from "node:os";
@@ -20,8 +21,12 @@ import { join, resolve } from "node:path";
20
21
  import { directedAt } from "./db.js";
21
22
  function fail(msg) {
22
23
  // This CLI exits immediately after one small status line. A synchronous fd
23
- // write prevents piped output from being truncated by process.exit().
24
- writeFileSync(2, `agent-chat-check: ${msg}\n`);
24
+ // write prevents piped output from being truncated by process.exit(). If
25
+ // stderr itself is broken, preserve the documented error exit code.
26
+ try {
27
+ writeFileSync(2, `agent-chat-check: ${msg}\n`);
28
+ }
29
+ catch { }
25
30
  process.exit(2);
26
31
  }
27
32
  const USAGE = `agent-chat-check: one-shot, read-only unread probe.
@@ -37,6 +42,9 @@ Flags:
37
42
  Exit codes: 0 = updates exist (JSON status on stdout; rooms_with_updates names
38
43
  up to 20 firing rooms on the all-rooms path and
39
44
  rooms_with_updates_truncated:true means more fired), 1 = nothing new, 2 = error.
45
+ An explicit --agent is validated: an unknown id is an error, and a terminally
46
+ retired one exits 2 with retired_identity rather than being reported as left or
47
+ quiet. --since without --agent asks about room traffic and needs no identity.
40
48
  `;
41
49
  function parseArgs(argv) {
42
50
  const out = { mentionsOnly: false };
@@ -61,7 +69,9 @@ function parseArgs(argv) {
61
69
  fail(`${flag} requires a value`);
62
70
  if (v.trim().length === 0)
63
71
  fail(`${flag} requires a non-empty value`);
64
- return v;
72
+ // Match poller.ts: both CLIs receive the same generated flags, so they
73
+ // must normalize `--agent ' bob '` to the same persona.
74
+ return v.trim();
65
75
  };
66
76
  if (a === "--mentions-only") {
67
77
  if (inline !== undefined)
@@ -75,7 +85,7 @@ function parseArgs(argv) {
75
85
  out.agent = take(a);
76
86
  }
77
87
  else if (a === "--since") {
78
- const v = take(a).trim();
88
+ const v = take(a);
79
89
  // Digits only: Number() would also admit "0x10" and "1e3".
80
90
  if (!/^\d+$/.test(v))
81
91
  fail("--since must be a non-negative integer");
@@ -122,10 +132,6 @@ if (args.since !== undefined && args.room === undefined) {
122
132
  if (args.mentionsOnly && !args.agent) {
123
133
  fail("--mentions-only requires --agent");
124
134
  }
125
- if (args.since !== undefined &&
126
- (!Number.isInteger(args.since) || args.since < 0)) {
127
- fail("--since must be a non-negative integer");
128
- }
129
135
  const path = resolveDbPath(args.db);
130
136
  if (!existsSync(path))
131
137
  fail(`db not found: ${path}`);
@@ -140,22 +146,36 @@ try {
140
146
  db.pragma("busy_timeout = 2000");
141
147
  db.pragma("query_only = ON");
142
148
  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).
149
+ // All-rooms watch: state and unread counts share one snapshot.
147
150
  const agent = args.agent;
148
151
  if (!agent)
149
152
  fail("--agent is required when watching all rooms");
150
153
  const counts = db
151
154
  .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")
155
+ // Identity BEFORE membership, in the same snapshot. A retired persona
156
+ // has soft-left everything, so without this it reports left_all_rooms
157
+ // and tells the caller to rejoin -- advice a terminal identity cannot
158
+ // act on. This diagnostic holds no binding, but retirement is a
159
+ // property of the IDENTITY, so it can and must see it.
160
+ const persona = db
161
+ .prepare("SELECT retired_at FROM agents WHERE id = ?")
162
+ .get(agent);
163
+ if (!persona)
164
+ return "unknown_agent";
165
+ if (persona.retired_at !== null)
166
+ return "retired";
167
+ const memberships = db
168
+ .prepare(`SELECT COUNT(*) AS total,
169
+ COALESCE(
170
+ SUM(CASE WHEN left_at IS NULL THEN 1 ELSE 0 END),
171
+ 0
172
+ ) AS present
173
+ FROM memberships WHERE agent_id = ?`)
156
174
  .get(agent);
157
- if (rooms === 0)
158
- return null;
175
+ if (memberships.total === 0)
176
+ return "no_memberships";
177
+ if (memberships.present === 0)
178
+ return "left_all";
159
179
  const { c: unread } = db
160
180
  .prepare(`SELECT COUNT(*) AS c FROM messages g
161
181
  JOIN memberships mb ON mb.room_id = g.room_id
@@ -206,7 +226,7 @@ try {
206
226
  : grouped;
207
227
  }
208
228
  return {
209
- rooms,
229
+ rooms: memberships.present,
210
230
  unread,
211
231
  unreadMentions,
212
232
  roomsWithUpdates,
@@ -214,8 +234,22 @@ try {
214
234
  };
215
235
  })
216
236
  .deferred();
217
- if (counts === null)
218
- fail(`agent "${agent}" is not a member of any room`);
237
+ if (counts === "unknown_agent") {
238
+ fail(`no persona "${agent}"; check the id`);
239
+ }
240
+ if (counts === "retired") {
241
+ fail(`retired_identity: agent "${agent}" was terminally retired; its ` +
242
+ "history stands, but it has no actionable unread and can never " +
243
+ "rejoin. Check a live persona instead.");
244
+ }
245
+ if (counts === "no_memberships") {
246
+ fail(`no_room_memberships: agent "${agent}" has no room memberships; ` +
247
+ "join a room first");
248
+ }
249
+ if (counts === "left_all") {
250
+ fail(`left_all_rooms: agent "${agent}" has LEFT every joined room; ` +
251
+ "rejoin one with join_room -- its read position and role are preserved");
252
+ }
219
253
  const { rooms, unread, unreadMentions, roomsWithUpdates, roomsWithUpdatesTruncated, } = counts;
220
254
  db.close();
221
255
  const hasUpdates = args.mentionsOnly ? unreadMentions > 0 : unread > 0;
@@ -235,36 +269,64 @@ try {
235
269
  }) + "\n");
236
270
  process.exit(hasUpdates ? 0 : 1);
237
271
  }
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
272
  if (args.since === undefined && !args.agent) {
251
273
  fail("--agent is required unless --since is given");
252
274
  }
253
- // One DEFERRED transaction = one snapshot for baseline + counts + latest.
275
+ const roomRef = args.room;
276
+ if (!roomRef)
277
+ fail("--room is required for a scoped probe");
278
+ // Room resolution, baseline, and counts share one snapshot. Resolving before
279
+ // the transaction let a concurrent delete turn --since into a false quiet.
254
280
  const snap = db
255
281
  .transaction(() => {
282
+ // Only treat a numeric ref as an id when it is exactly representable.
283
+ let room = /^\d+$/.test(roomRef) && Number.isSafeInteger(Number(roomRef))
284
+ ? db
285
+ .prepare("SELECT id FROM rooms WHERE id = ?")
286
+ .get(Number(roomRef))
287
+ : undefined;
288
+ if (!room) {
289
+ room = db
290
+ .prepare("SELECT id FROM rooms WHERE name = ?")
291
+ .get(roomRef);
292
+ }
293
+ if (!room)
294
+ return { state: "missing" };
295
+ const roomId = room.id;
296
+ // Same-snapshot identity validation whenever an explicit --agent is
297
+ // given, which covers BOTH the marker path and --since --agent. A
298
+ // --since probe with no agent is a room-traffic question that needs no
299
+ // identity, so it stays untouched.
300
+ if (args.agent !== undefined) {
301
+ const persona = db
302
+ .prepare("SELECT retired_at FROM agents WHERE id = ?")
303
+ .get(args.agent);
304
+ if (!persona)
305
+ return { state: "unknown_agent", roomId };
306
+ if (persona.retired_at !== null) {
307
+ return { state: "retired", roomId };
308
+ }
309
+ }
256
310
  let baseline;
257
311
  if (args.since !== undefined) {
258
312
  baseline = args.since;
259
313
  }
260
314
  else {
315
+ // left_at rides as DATA, not as a WHERE predicate: filtering it out
316
+ // would collapse "left" into "never joined", and those need different
317
+ // remedies (rejoin vs join). The all-rooms path above already excludes
318
+ // left memberships; reporting actionable unread here for a room whose
319
+ // catch_up now refuses to read, and whose scoped watcher refuses to
320
+ // arm, made one room give three different answers.
261
321
  const m = db
262
- .prepare(`SELECT mb.last_read_seq AS last_read_seq
322
+ .prepare(`SELECT mb.last_read_seq AS last_read_seq, mb.left_at AS left_at
263
323
  FROM memberships mb
264
324
  WHERE mb.room_id = ? AND mb.agent_id = ?`)
265
325
  .get(roomId, args.agent);
266
326
  if (!m)
267
- return null;
327
+ return { state: "not_joined", roomId };
328
+ if (m.left_at !== null)
329
+ return { state: "left", roomId };
268
330
  baseline = m.last_read_seq;
269
331
  }
270
332
  // Exclude the agent's own messages: posting should not make you "have updates".
@@ -287,13 +349,38 @@ try {
287
349
  const latest = db
288
350
  .prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM messages WHERE room_id = ?")
289
351
  .get(roomId).s;
290
- return { baseline, unread, unreadMentions, latest };
352
+ return {
353
+ state: "ok",
354
+ roomId,
355
+ baseline,
356
+ unread,
357
+ unreadMentions,
358
+ latest,
359
+ };
291
360
  })
292
361
  .deferred();
293
- if (snap === null) {
294
- fail(`agent "${args.agent}" is not a member of room ${roomId}; join first or pass --since`);
362
+ if (snap.state === "missing") {
363
+ fail(`no room "${roomRef}"`);
364
+ }
365
+ if (snap.state === "unknown_agent") {
366
+ fail(`no persona "${args.agent}"; check the id`);
367
+ }
368
+ if (snap.state === "retired") {
369
+ fail(`retired_identity: agent "${args.agent}" was terminally retired; its ` +
370
+ "history stands, but it has no actionable unread in this room and can " +
371
+ "never rejoin. Check a live persona instead.");
372
+ }
373
+ if (snap.state === "not_joined") {
374
+ fail(`agent "${args.agent}" is not a member of room ${snap.roomId}; ` +
375
+ "join first or pass --since");
376
+ }
377
+ if (snap.state === "left") {
378
+ fail(`agent "${args.agent}" has LEFT room ${snap.roomId}, so its unread there is not ` +
379
+ `actionable: catch_up refuses the room and a scoped watcher will not arm. ` +
380
+ `Rejoin with join_room -- the read position is preserved -- or pass ` +
381
+ `--since to read the room's traffic without a membership.`);
295
382
  }
296
- const { baseline, unread, unreadMentions, latest } = snap;
383
+ const { roomId, baseline, unread, unreadMentions, latest } = snap;
297
384
  db.close();
298
385
  const hasUpdates = args.mentionsOnly ? unreadMentions > 0 : unread > 0;
299
386
  writeFileSync(1, JSON.stringify({