multi-agent-collaboration-mcp 0.13.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -281
- package/dist/bounded-lines.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/check.js +131 -44
- package/dist/db.js +935 -583
- package/dist/index.js +571 -575
- package/dist/poller.js +179 -78
- package/package.json +3 -2
- package/scripts/connection-probe.mjs +220 -0
- package/web/index.html +783 -256
- package/web/server.mjs +377 -159
package/README.md
CHANGED
|
@@ -1,305 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent Chat: One Room for Your Coding Agents
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
##
|
|
17
|
+
## Start with the smallest useful team
|
|
39
18
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
36
|
+
Tell the builder:
|
|
68
37
|
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
package/dist/bounded-lines.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.
|
|
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
|
|
13
|
-
// "does this persona have unread work",
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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:
|
|
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
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
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 (
|
|
158
|
-
return
|
|
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 ===
|
|
218
|
-
fail(`
|
|
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
|
-
|
|
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
|
|
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 {
|
|
352
|
+
return {
|
|
353
|
+
state: "ok",
|
|
354
|
+
roomId,
|
|
355
|
+
baseline,
|
|
356
|
+
unread,
|
|
357
|
+
unreadMentions,
|
|
358
|
+
latest,
|
|
359
|
+
};
|
|
291
360
|
})
|
|
292
361
|
.deferred();
|
|
293
|
-
if (snap ===
|
|
294
|
-
fail(`
|
|
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({
|