multi-agent-collaboration-mcp 0.16.0 → 0.17.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
@@ -26,6 +26,12 @@ work.
26
26
  The [AI team playbook](<docs/AI Team Playbook.md>) gives copyable prompts and
27
27
  handoffs for two-, three-, and four-agent teams.
28
28
 
29
+ For larger or higher-risk teams, paste the
30
+ [multi-model project-management operating guide](<docs/Multi-Model AI Project Management Operating Guide.md>)
31
+ into the PM LLM's standing context. It defines a model-neutral PM role and
32
+ concrete rules for evidence, authority, assignments, ownership, review,
33
+ verification, and handoff.
34
+
29
35
  Tell the lead:
30
36
 
31
37
  ```text
@@ -1 +1 @@
1
- {"version":"0.16.0","commit":"f7ce2c5","built_at":"2026-07-29T22:35:22.853Z","artifact_hash":"e896bfdae803b7a1d27dfa9c787c9c7291b60acdc40ed906c8421b8388ee133b"}
1
+ {"version":"0.17.0","commit":"e51920e","built_at":"2026-08-02T07:33:05.580Z","artifact_hash":"f1d5be7bb3ff1b80303165848d201ec8938187d9d97a68ab6257d751a1a437b9"}
package/dist/db.js CHANGED
@@ -32,11 +32,19 @@ export const MAX_CROSSED_PREVIEW_CHARS = 2_000;
32
32
  /** Public MCP/store caps used to keep direct callers from bypassing bounded
33
33
  * reads with values the tool schemas would reject. */
34
34
  const MAX_BULK_RESULT_CHARS = 400_000;
35
- const MAX_CATCH_UP_ROWS = 500;
35
+ const MIN_NONADVANCING_RESULT_CHARS = 1_000;
36
+ const MAX_BULK_READ_ROWS = 500;
36
37
  const MAX_GET_MESSAGE_CHARS = 400_000;
38
+ const MAX_RECIPIENT_IDS = 100;
37
39
  /** Caller-supplied post idempotency keys are opaque, room/author scoped, and
38
40
  * intentionally small enough to keep the sparse unique index cheap. */
39
41
  export const MAX_CLIENT_MESSAGE_ID_CHARS = 200;
42
+ function requireIntegerInRange(value, name, min, max) {
43
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
44
+ throw new Error(`${name} must be an integer from ${min} to ${max}`);
45
+ }
46
+ return value;
47
+ }
40
48
  /** Persona/human id cap. Exported because the server SIZES the canonical id it
41
49
  * generates against it before allocating, rather than discovering the overflow
42
50
  * as a store assertion after the fact. */
@@ -1003,6 +1011,7 @@ export class ChatStore {
1003
1011
  listRooms(limit = 200, afterId = 0) {
1004
1012
  const PREVIEW = 300;
1005
1013
  const lim = Math.max(1, Math.floor(limit));
1014
+ const cursor = requireIntegerInRange(afterId, "list_rooms after_id", 0, Number.MAX_SAFE_INTEGER);
1006
1015
  // Rows and total in ONE deferred snapshot: read as separate statements, a
1007
1016
  // concurrent create_room/delete_room BETWEEN them yields an internally
1008
1017
  // contradictory page (e.g. total:4 with no next_id, so the keyset pager
@@ -1024,7 +1033,7 @@ export class ChatStore {
1024
1033
  (SELECT created_at FROM messages g
1025
1034
  WHERE g.room_id = r.id ORDER BY g.seq DESC LIMIT 1) AS last_activity
1026
1035
  FROM rooms r WHERE r.id > ? ORDER BY r.id LIMIT ?`)
1027
- .all(Math.max(0, Math.floor(afterId)), lim + 1);
1036
+ .all(cursor, lim + 1);
1028
1037
  const { c: total } = this.db
1029
1038
  .prepare("SELECT COUNT(*) AS c FROM rooms")
1030
1039
  .get();
@@ -1399,6 +1408,9 @@ export class ChatStore {
1399
1408
  retiredIds(ids) {
1400
1409
  if (ids.length === 0)
1401
1410
  return new Set();
1411
+ if (ids.length > MAX_RECIPIENT_IDS) {
1412
+ throw new Error(`retired_ids accepts at most ${MAX_RECIPIENT_IDS} ids`);
1413
+ }
1402
1414
  const placeholders = ids.map(() => "?").join(",");
1403
1415
  const rows = this.db
1404
1416
  .prepare(`SELECT id FROM agents
@@ -1687,9 +1699,9 @@ export class ChatStore {
1687
1699
  // rejoins (INSERT OR IGNORE keeps the row); rows come back in join order.
1688
1700
  let keyset = "";
1689
1701
  const rowParams = { ...base, lim: lim + 1 };
1690
- if (after !== undefined && Number.isFinite(after)) {
1702
+ if (after !== undefined) {
1691
1703
  keyset = ` AND m.rowid > @after`;
1692
- rowParams.after = Math.floor(after);
1704
+ rowParams.after = requireIntegerInRange(after, "list_agents after", 0, Number.MAX_SAFE_INTEGER);
1693
1705
  }
1694
1706
  // Rows and count in ONE deferred snapshot so total cannot disagree with the
1695
1707
  // page (parity with listRooms). Fetch one MORE than asked to detect a page.
@@ -2338,6 +2350,9 @@ export class ChatStore {
2338
2350
  recipientStatus(roomId, ids, activeWithinMinutes) {
2339
2351
  if (ids.length === 0)
2340
2352
  return [];
2353
+ if (ids.length > MAX_RECIPIENT_IDS) {
2354
+ throw new Error(`recipient_status accepts at most ${MAX_RECIPIENT_IDS} ids`);
2355
+ }
2341
2356
  const placeholders = ids.map(() => "?").join(",");
2342
2357
  // marker_behind baseline. Read alongside the rows without a transaction:
2343
2358
  // this is a liveness heuristic, not an invariant, and the method already
@@ -2498,13 +2513,14 @@ export class ChatStore {
2498
2513
  * delivered over budget. Oversized bodies arrive truncated with markers;
2499
2514
  * page them via get_message.
2500
2515
  */
2501
- getThread(roomId, seq, maxDepth = 3, previewChars) {
2516
+ getThread(roomId, seq, maxDepth = 3, previewChars, maxBytes = DEFAULT_MAX_BYTES) {
2517
+ maxBytes = requireIntegerInRange(maxBytes, "get_thread max_bytes", MIN_NONADVANCING_RESULT_CHARS, DEFAULT_MAX_BYTES);
2502
2518
  const tx = this.db.transaction(() => {
2503
- const focalRow = this.getRawMessage(roomId, seq);
2519
+ const focalRow = this.getRawMessage(roomId, seq, maxBytes);
2504
2520
  if (!focalRow)
2505
2521
  return undefined;
2506
2522
  const mapPlain = (r, pc) => this.rowToMessage(r, pc);
2507
- const budget = DEFAULT_MAX_BYTES - THREAD_ENVELOPE;
2523
+ const budget = maxBytes - THREAD_ENVELOPE;
2508
2524
  // Reserve the parent's slot before spending on the focal message: a stub
2509
2525
  // allowance when a parent exists, 4 chars of literal null otherwise. The
2510
2526
  // trailing 2 covers an empty replies array.
@@ -2514,7 +2530,7 @@ export class ChatStore {
2514
2530
  let remaining = budget - JSON.stringify(message).length;
2515
2531
  let parent = null;
2516
2532
  if (parentSeq !== null) {
2517
- const parentRow = this.getRawMessage(roomId, parentSeq);
2533
+ const parentRow = this.getRawMessage(roomId, parentSeq, maxBytes);
2518
2534
  if (parentRow) {
2519
2535
  // Leave a stub allowance (plus array brackets) for the replies when
2520
2536
  // more than that remains; otherwise the parent gets a stub itself.
@@ -2544,7 +2560,7 @@ export class ChatStore {
2544
2560
  ORDER BY path
2545
2561
  LIMIT @lim
2546
2562
  )
2547
- SELECT ${messageCols(DEFAULT_MAX_BYTES)}, d.depth AS depth
2563
+ SELECT ${messageCols(maxBytes)}, d.depth AS depth
2548
2564
  FROM descendants d
2549
2565
  CROSS JOIN messages g
2550
2566
  LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
@@ -2705,7 +2721,7 @@ export class ChatStore {
2705
2721
  if (!Number.isFinite(limit)) {
2706
2722
  throw new Error("catch_up limit must be finite");
2707
2723
  }
2708
- const pageLimit = Math.min(MAX_CATCH_UP_ROWS, Math.max(1, Math.floor(limit)));
2724
+ const pageLimit = Math.min(MAX_BULK_READ_ROWS, Math.max(1, Math.floor(limit)));
2709
2725
  // Advancing path: read the cursor, fetch, and advance inside one IMMEDIATE
2710
2726
  // transaction. A deferred WAL transaction could let another writer commit
2711
2727
  // after this read snapshot, then fail the cursor update with
@@ -2856,6 +2872,9 @@ export class ChatStore {
2856
2872
  * snapshot.
2857
2873
  */
2858
2874
  myMentions(agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, afterId = 0) {
2875
+ limit = requireIntegerInRange(limit, "my_mentions limit", 1, MAX_BULK_READ_ROWS);
2876
+ maxBytes = requireIntegerInRange(maxBytes, "my_mentions max_bytes", MIN_CATCH_UP_RESULT_BUDGET, MAX_BULK_RESULT_CHARS);
2877
+ afterId = requireIntegerInRange(afterId, "my_mentions after_id", 0, Number.MAX_SAFE_INTEGER);
2859
2878
  const tx = this.db.transaction(() => {
2860
2879
  // Fetch ONE more than `limit` so a page cut by the ROW limit (not the
2861
2880
  // byte budget) is detectable. Without the probe, my_mentions was the only
@@ -2939,6 +2958,11 @@ export class ChatStore {
2939
2958
  * `limit`; else older than beforeSeq.
2940
2959
  */
2941
2960
  readHistory(roomId, limit, beforeSeq, previewChars, maxBytes = DEFAULT_MAX_BYTES) {
2961
+ limit = requireIntegerInRange(limit, "read_history limit", 1, MAX_BULK_READ_ROWS);
2962
+ maxBytes = requireIntegerInRange(maxBytes, "read_history max_bytes", MIN_CATCH_UP_RESULT_BUDGET, MAX_BULK_RESULT_CHARS);
2963
+ if (beforeSeq !== undefined) {
2964
+ requireIntegerInRange(beforeSeq, "read_history before_seq", 1, Number.MAX_SAFE_INTEGER);
2965
+ }
2942
2966
  const conds = ["g.room_id = ?"];
2943
2967
  const params = [roomId];
2944
2968
  if (beforeSeq !== undefined) {
@@ -2978,6 +3002,9 @@ export class ChatStore {
2978
3002
  * Returns the previous and new marker plus the room's latest seq.
2979
3003
  */
2980
3004
  markRead(roomId, agentId, seq) {
3005
+ if (seq !== undefined) {
3006
+ requireIntegerInRange(seq, "mark_read seq", 0, Number.MAX_SAFE_INTEGER);
3007
+ }
2981
3008
  const tx = this.db.transaction(() => {
2982
3009
  this.requireLive(agentId);
2983
3010
  // Advancing a marker requires presence and reports the prior cursor.
@@ -3006,21 +3033,22 @@ export class ChatStore {
3006
3033
  * relevance sort can avoid; callers wanting exactly-once delivery should page
3007
3034
  * in one burst.
3008
3035
  */
3009
- searchMessages(roomId, query, limit, offset = 0) {
3036
+ searchMessages(roomId, query, limit, offset = 0, maxBytes = DEFAULT_MAX_BYTES) {
3037
+ maxBytes = requireIntegerInRange(maxBytes, "search_messages max_bytes", MIN_NONADVANCING_RESULT_CHARS, DEFAULT_MAX_BYTES);
3010
3038
  const off = Math.max(0, Math.floor(offset));
3011
3039
  // Fetch one MORE than asked: a full `limit` page does not by itself prove
3012
3040
  // more exist, so the extra row is the definitive "there is a next page"
3013
3041
  // probe (a page of exactly `limit` matches used to emit a false
3014
3042
  // next_offset that returned nothing).
3015
- const { rows, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(DEFAULT_MAX_BYTES)}
3043
+ const { rows, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(maxBytes)}
3016
3044
  FROM messages_fts f
3017
3045
  JOIN messages g ON g.id = f.rowid
3018
3046
  LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
3019
3047
  WHERE f.body MATCH ? AND g.room_id = ?
3020
- ORDER BY rank, g.id LIMIT ? OFFSET ?`), [query, roomId, limit + 1, off], DEFAULT_MAX_BYTES);
3048
+ ORDER BY rank, g.id LIMIT ? OFFSET ?`), [query, roomId, limit + 1, off], maxBytes);
3021
3049
  const hasExtra = rows.length > limit;
3022
3050
  const page = hasExtra ? rows.slice(0, limit) : rows;
3023
- const { messages, byteLimited } = this.boundByBytes(page, undefined, DEFAULT_MAX_BYTES - SEARCH_ENVELOPE, (r, pc) => this.rowToMessage(r, pc));
3051
+ const { messages, byteLimited } = this.boundByBytes(page, undefined, maxBytes - SEARCH_ENVELOPE, (r, pc) => this.rowToMessage(r, pc));
3024
3052
  // More remain if the byte bound cut the page, a genuine extra match exists
3025
3053
  // beyond `limit`, OR fetchBounded stopped on the raw byte budget with
3026
3054
  // matches unfetched (!exhausted) -- the last case is invisible to
package/dist/index.js CHANGED
@@ -120,7 +120,7 @@ SIZE AND PAGING
120
120
 
121
121
  POSTING
122
122
  - crossed counts ALL unread from others past your marker at post time (old backlog included, not only mid-composition arrivals); crossed_directed says how many are aimed at you; crossed_range gives the seq span. If crossed > 0, catch_up before acting on replies. crossed_preview_chars opts into bounded previews of the crossed messages in the same response.
123
- - Dispositive posts (verdicts, commissions, dispositions): if_last_read_seq is a conditional post -- rejected (posted:false) if ANYTHING from others landed past your token, with bounded crossed previews returned; call catch_up for the complete delta before retrying. If pruning removed evidence after the token, it rejects conservatively with rejected:evidence_pruned and no invented previews. A token ahead of the target room's effective cursor is invalid and fails before posting. client_message_id makes an exact lost-response retry return the original seq instead of inserting twice; its guarantee lasts while that message is retained. Repeat the same explicit room or expected_room on retry so active-room drift cannot create a post in another room; a deduplicated response does not replay the original crossed/recipient snapshot, so catch_up for current state. room: posts to a named joined room without switching the active room; expected_room asserts which room is active. Never use the CAS on routine traffic: crossing is normal, the CAS is for posts whose validity depends on having read everything.
123
+ - Use if_last_read_seq for a directive, assignment, or verdict whose validity depends on no peer message arriving after your last catch_up. It is a conditional post -- rejected (posted:false) if ANYTHING from others landed past your token, with bounded crossed previews returned; call catch_up for the complete delta before retrying. If pruning removed evidence after the token, it rejects conservatively with rejected:evidence_pruned and no invented previews. A token ahead of the target room's effective cursor is invalid and fails before posting. client_message_id makes an exact lost-response retry return the original seq instead of inserting twice; its guarantee lasts while that message is retained. Repeat the same explicit room or expected_room on retry so active-room drift cannot create a post in another room; a deduplicated response does not replay the original crossed/recipient snapshot, so catch_up for current state. room: posts to a named joined room without switching the active room; expected_room asserts which room is active. Never use the CAS on routine traffic: crossing is normal, the CAS is for posts whose validity depends on having read everything.
124
124
  - recipients reports factual room-local state: status, idle_seconds, last_read_seq, marker_behind. A new unread tag normally adds one to marker_behind. delivery_warnings is definitive for never-joined/left/retired recipients; a long-idle warning is emitted only for pre-existing lag and states observed facts, never a responsiveness prediction.
125
125
  - status/idle_seconds/last_seen measure LISTENER recency: an MCP call from the bound runtime, or the two-minute heartbeat of a watcher it armed. active therefore means a runtime is reachable, not that the model is reading, reasoning, or able to wake, and an armed seat stays active no matter how long its model has been silent. The absence of a long-idle warning is not evidence anyone is listening; watching:true (an open blocking call) is the stronger claim.
126
126
  - supersedes_seq corrects YOUR OWN earlier message; readers see superseded_by on it. reply_to_seq threads; the log stays flat and globally ordered.
@@ -143,7 +143,7 @@ IDENTITY
143
143
  - Human web participants use the reserved 'human-' prefix with a numeric ordinal (human-alex-1). The number prevents two independent browsers from colliding on one name; it is not ownership and not authentication.
144
144
 
145
145
  BACKGROUND POLLER
146
- - Run the command returned by join_room or wait_for_messages as a BACKGROUND task. Before arming, call catch_up until remaining is 0 or stops falling. The watcher probes before its first sleep, so unread traffic makes it exit immediately. 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, a live-PID watcher lock, retired_identity, 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>, --owner-pid <pid>. Your own posts never wake it.
146
+ - Launch the command returned by join_room or wait_for_messages as the tracked BACKGROUND task. The tracked task must remain attached to the poller's stdout and exit; if a wrapper is required, it must stay alive until the poller exits. Before arming, call catch_up until remaining is 0 or stops falling. The watcher probes before its first sleep, so unread traffic makes it exit immediately. 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, a live-PID watcher lock, retired_identity, 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>, --owner-pid <pid>. 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. This MCP does not replace an exited watcher; watching resumes only when a client runs a new command. 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
@@ -1077,8 +1077,11 @@ server.registerTool("join_room", {
1077
1077
  // persona (see the server instructions).
1078
1078
  poller_cmd: pollerCmd(agentId),
1079
1079
  next: "call catch_up until it returns `remaining: 0` or the value stops " +
1080
- "falling; then run poller_cmd as a background task before starting " +
1081
- "work. The watcher checks immediately, so unread traffic makes it " +
1080
+ "falling; then launch poller_cmd as the client's tracked background " +
1081
+ "task before starting work. The tracked task must remain attached to " +
1082
+ "the poller's stdout and exit; any wrapper must stay alive until the " +
1083
+ "poller exits. The watcher checks " +
1084
+ "immediately, so unread traffic makes it " +
1082
1085
  "exit immediately. This MCP server returns the command but cannot " +
1083
1086
  "launch it.",
1084
1087
  presence_note: "active = recent MCP or poller contact, or a live blocking wait. " +
@@ -1292,8 +1295,9 @@ server.registerTool("post_message", {
1292
1295
  "insert. `posted:true` means committed to SQLite only, not that a " +
1293
1296
  "recipient was woken, acknowledged, or began processing it. A " +
1294
1297
  "deduplicated retry returns the original seq/key only; catch " +
1295
- "up for current state. For a " +
1296
- "dispositive post, use `if_last_read_seq` + `expected_room`; use " +
1298
+ "up for current state. For a directive, assignment, or verdict that " +
1299
+ "would be invalid if peer traffic arrived after your last catch_up, use " +
1300
+ "`if_last_read_seq` + `expected_room`; use " +
1297
1301
  "`client_message_id` to deduplicate an exact lost-response retry. " +
1298
1302
  "`crossed_preview_chars` max is 2000. `priority:true` survives explicit " +
1299
1303
  "priority-only backlog triage; `supersedes_seq` corrects your own post.",
@@ -1379,9 +1383,11 @@ server.registerTool("post_message", {
1379
1383
  .int()
1380
1384
  .nonnegative()
1381
1385
  .optional()
1382
- .describe("Conditional post (CAS) for dispositive messages: reject if ANY " +
1383
- "message from others carries a seq above this (use your last " +
1384
- "catch_up's new_last_read_seq). A token ahead of this room's " +
1386
+ .describe("Conditional post (CAS) for a directive, assignment, or verdict " +
1387
+ "that would be invalid if peer traffic arrived after your last " +
1388
+ "catch_up: reject if ANY message from others carries a seq above " +
1389
+ "this (use your last catch_up's new_last_read_seq). A token ahead " +
1390
+ "of this room's " +
1385
1391
  "effective read cursor is invalid and rejected before posting. " +
1386
1392
  "A stale rejection returns posted:false with bounded crossed " +
1387
1393
  "previews; call catch_up for the full delta, then re-send the " +
@@ -1709,6 +1715,8 @@ server.registerTool("catch_up", {
1709
1715
  first = advancingRead(waitSeconds === 0);
1710
1716
  }
1711
1717
  catch (e) {
1718
+ if (e instanceof PersonaLostError)
1719
+ throw e;
1712
1720
  if (!store.getRoom(roomId))
1713
1721
  return roomDeletedResult();
1714
1722
  throw e;
@@ -1771,6 +1779,8 @@ server.registerTool("catch_up", {
1771
1779
  hit = advancingRead(false);
1772
1780
  }
1773
1781
  catch (e) {
1782
+ if (e instanceof PersonaLostError)
1783
+ throw e;
1774
1784
  if (!store.getRoom(roomId))
1775
1785
  return roomDeletedResult(true);
1776
1786
  throw e;
@@ -1790,6 +1800,8 @@ server.registerTool("catch_up", {
1790
1800
  last = advancingRead(true);
1791
1801
  }
1792
1802
  catch (e) {
1803
+ if (e instanceof PersonaLostError)
1804
+ throw e;
1793
1805
  if (!store.getRoom(roomId))
1794
1806
  return roomDeletedResult(true);
1795
1807
  throw e;
@@ -2026,7 +2038,9 @@ server.registerTool("wait_for_messages", {
2026
2038
  return ok({
2027
2039
  command,
2028
2040
  run_as: "background process (do not wait for it inline)",
2029
- how_to: "Run `command` in the background. On exit 0, parse stdout: " +
2041
+ how_to: "Launch `command` as your client's tracked background task. The " +
2042
+ "tracked task must remain attached to the poller's stdout and exit; " +
2043
+ "any wrapper must stay alive until the poller exits. On exit 0, parse stdout: " +
2030
2044
  "has_updates:true names the room to catch_up; has_updates:false is a " +
2031
2045
  "normal quiet deadline. Exit 2 is an error, duplicate watcher, or a " +
2032
2046
  "terminal room/binding state; inspect stderr before re-arming. The " +
@@ -2209,11 +2223,12 @@ server.registerTool("get_thread", {
2209
2223
  touchSession();
2210
2224
  const { agentId, roomId, roomName } = resolveJoinedRoom(room);
2211
2225
  touchCapturedRoom(roomId, agentId);
2212
- const thread = store.getThread(roomId, seq, max_depth ?? 3, preview_chars);
2226
+ const lost = lossDisclosure(agentId);
2227
+ const thread = store.getThread(roomId, seq, max_depth ?? 3, preview_chars, DEFAULT_MAX_BYTES - disclosureReserve(lost));
2213
2228
  if (!thread) {
2214
2229
  return fail(`no message ${seq} in room "${roomName}"`);
2215
2230
  }
2216
- return ok({ ...lossDisclosure(agentId), ...thread });
2231
+ return ok({ ...lost, ...thread });
2217
2232
  }
2218
2233
  catch (e) {
2219
2234
  return failFrom(e);
@@ -2269,9 +2284,10 @@ server.registerTool("search_messages", {
2269
2284
  try {
2270
2285
  touchSession();
2271
2286
  const { agentId, roomId } = requireActive();
2287
+ const lost = lossDisclosure(agentId);
2272
2288
  return ok({
2273
- ...lossDisclosure(agentId),
2274
- ...store.searchMessages(roomId, query, limit ?? 20, offset ?? 0),
2289
+ ...lost,
2290
+ ...store.searchMessages(roomId, query, limit ?? 20, offset ?? 0, DEFAULT_MAX_BYTES - disclosureReserve(lost)),
2275
2291
  });
2276
2292
  }
2277
2293
  catch (e) {
package/dist/poller.js CHANGED
@@ -134,6 +134,9 @@ function dbPath(override) {
134
134
  return join(homedir(), ".agent-chat-mcp", "chat.db");
135
135
  }
136
136
  function processIsAlive(pid) {
137
+ // Existence only, not process identity. A recycled PID is indistinguishable
138
+ // here from the process that originally owned it; closing that portability
139
+ // gap requires a process-instance protocol rather than another PID check.
137
140
  if (!Number.isSafeInteger(pid) || pid < 1)
138
141
  return false;
139
142
  try {
@@ -144,6 +147,8 @@ function processIsAlive(pid) {
144
147
  return error.code !== "ESRCH";
145
148
  }
146
149
  }
150
+ const LOCK_METADATA_READ_ATTEMPTS = 5;
151
+ const LOCK_METADATA_RETRY_DELAY_MS = 25;
147
152
  function acquireLock(path, token) {
148
153
  try {
149
154
  const fd = openSync(path, "wx", 0o600);
@@ -164,11 +169,34 @@ function acquireLock(path, token) {
164
169
  catch (error) {
165
170
  if (error.code !== "EEXIST")
166
171
  throw error;
167
- let owner = 0;
168
- try {
169
- owner = Number(JSON.parse(readFileSync(path, "utf8")).pid);
172
+ let owner;
173
+ const retrySignal = new Int32Array(new SharedArrayBuffer(4));
174
+ for (let attempt = 0; attempt < LOCK_METADATA_READ_ATTEMPTS; attempt++) {
175
+ try {
176
+ const metadata = JSON.parse(readFileSync(path, "utf8"));
177
+ if (typeof metadata.pid === "number" &&
178
+ Number.isSafeInteger(metadata.pid) &&
179
+ metadata.pid > 0 &&
180
+ typeof metadata.token === "string" &&
181
+ metadata.token.length > 0) {
182
+ owner = metadata.pid;
183
+ break;
184
+ }
185
+ }
186
+ catch { }
187
+ if (attempt + 1 < LOCK_METADATA_READ_ATTEMPTS) {
188
+ Atomics.wait(retrySignal, 0, 0, LOCK_METADATA_RETRY_DELAY_MS);
189
+ }
190
+ }
191
+ if (owner === undefined) {
192
+ // The exclusive create publishes the pathname before the tiny metadata
193
+ // write completes. Give that bounded publication window time to settle,
194
+ // then preserve exclusion without falsely calling an invalid lock stale.
195
+ argumentError(`watcher lock metadata could not be read or validated; the lock may be ` +
196
+ `initializing or stale, so acquisition is refused (lock: ${path}). ` +
197
+ `Retry without removing it; inspect this exact lock before any manual ` +
198
+ `recovery`);
170
199
  }
171
- catch { }
172
200
  if (processIsAlive(owner)) {
173
201
  argumentError(`watcher lock references live pid ${owner}; refusing a duplicate (lock: ${path})`);
174
202
  }
@@ -0,0 +1,152 @@
1
+ # AI Team Playbook
2
+
3
+ Use this guide after installing Agent Chat. It is for people who want several
4
+ coding agents to produce one project without becoming the message carrier.
5
+ Start with two seats. Add a seat only when you can name its job in one sentence.
6
+ Agent identity is self-reported, so use only clients you trust.
7
+
8
+ ## Choose the smallest team
9
+
10
+ | Seats | Roles | Use when |
11
+ | --- | --- | --- |
12
+ | Two | Project lead and builder | Recommended default: one owns the result and one implements it. |
13
+ | Three | Add an architect or reviewer | A named design question, subsystem, or independent review needs its own attention. |
14
+ | Four | Add an oversight reviewer | A long, high-risk, or policy-bound project needs an independent scope and progress check. |
15
+
16
+ Roles are jobs, not headcount; one agent can hold several. Every added seat must
17
+ catch up, keep its watcher armed, avoid conflicting edits, and report back. Add
18
+ a security, database, performance, or test specialist only for a named problem,
19
+ then remove that seat when the problem is closed.
20
+
21
+ The project lead is the PM and final tie-breaker. Use your strongest overall
22
+ reasoning model in that seat: it must understand both architecture and
23
+ implementation reports, reject drift, and decide. That is not automatically the
24
+ best code writer. A narrow technical problem may still need your strongest
25
+ specialist as architect.
26
+
27
+ ## Give each role a boundary
28
+
29
+ | Role | Owns | Does not own |
30
+ | --- | --- | --- |
31
+ | Project lead | Goal, scope, assignments, decisions, review, and completion | Unapproved changes to the human's goal or policy |
32
+ | Builder | Code, tests, file or task claims, and concrete progress reports | Project-wide scope changes |
33
+ | Architect or reviewer | Minimal design, alternatives, risks, and independent review | Editing the work while claiming to review it independently |
34
+ | Oversight reviewer | Plan compliance, policy, elapsed time, stalled work, and unsupported reports | Code correctness or technical review |
35
+
36
+ The oversight reviewer is the optional global manager. It reports concerns to
37
+ the PM and human, not competing orders to the builder. It can verify observable
38
+ state, but a silent agent may be working, throttled, disconnected, or gone.
39
+ Silence alone proves none of those.
40
+
41
+ ## Start the room
42
+
43
+ Replace `<room>` and the bracketed project details, then give each prompt to the
44
+ matching agent.
45
+
46
+ Open the local browser page beside the agent terminals. You can watch without
47
+ joining; enter a name only when you want to post, answer, or break a tie.
48
+
49
+ Project lead:
50
+
51
+ ```text
52
+ Use agent-chat. Create or join room "<room>" as project lead. Catch up, then post
53
+ the goal, required constraints, non-goals, and definition of done. Assign
54
+ bounded work, check claims and progress, resolve disagreements, reject
55
+ unapproved scope changes, and keep rearming the one-shot watcher. Do not declare
56
+ completion until reports and validation evidence agree.
57
+ ```
58
+
59
+ Builder:
60
+
61
+ ```text
62
+ Use agent-chat. Join room "<room>" as builder and catch up before acting. Claim
63
+ each file or task before editing. Implement only the assigned scope, test it,
64
+ report blockers, changed files, validation results, and remaining risks,
65
+ release claims when done, and keep rearming the one-shot watcher.
66
+ ```
67
+
68
+ Architect or reviewer:
69
+
70
+ ```text
71
+ Use agent-chat. Join room "<room>" as architect and reviewer. Investigate the
72
+ assigned design or review question. Compare the smallest viable alternatives,
73
+ their costs, failure cases, and validation. Do not edit while acting as an
74
+ independent reviewer. Post your recommendation and evidence, then keep
75
+ rearming the one-shot watcher.
76
+ ```
77
+
78
+ Oversight reviewer:
79
+
80
+ ```text
81
+ Use agent-chat. Join room "<room>" as oversight reviewer. Do not read or write code.
82
+ Compare current work with the original goal, constraints, and policy. Check
83
+ list_agents and list_claims, inspect the background watcher process directly,
84
+ and check git status, git log, timestamps, and validation evidence.
85
+ watching:false does not mean a background watcher is off. Flag stalled work,
86
+ stale claims, unsupported status reports, and scope drift. State when a
87
+ conclusion would require reading code. Escalate to the PM and human, not
88
+ directly to the builder, and keep rearming the one-shot watcher.
89
+ ```
90
+
91
+ ## Run one visible work loop
92
+
93
+ 1. **Assign.** The PM posts one bounded task with its owner, expected result,
94
+ relevant files, constraints, and required validation.
95
+ 2. **Claim.** Before changing shared files or tasks, the assigned agent uses an
96
+ Agent Chat claim. Claims are advisory and expire, so renew long work and
97
+ release finished work.
98
+ 3. **Build.** The agent works inside the assignment. It reports a blocker, a
99
+ required scope change, or a completed checkpoint instead of posting empty
100
+ status updates.
101
+ 4. **Report.** A useful completion report names changed files, commands run,
102
+ results, remaining risks, and anything not checked. "Done" alone is not
103
+ evidence.
104
+ 5. **Review.** The reviewer catches up, reads current evidence and code when
105
+ required, and posts findings. The author does not supply the independent
106
+ review of its own work.
107
+ 6. **Decide.** The PM records accepted findings, rejected findings with reasons,
108
+ tie-breaks, and any human-approved scope change. Keep disputes in the room;
109
+ correct your own earlier post with `supersedes_seq` instead of rewriting
110
+ history.
111
+ 7. **Close.** The PM checks that required validation passed, claims were
112
+ released, unresolved issues are named, and the result still matches the
113
+ original goal.
114
+
115
+ Before a consequential decision, catch up until no unread peer message remains.
116
+ If new traffic crossed a post, read it before treating the post as settled.
117
+
118
+ ## Keep agents reachable
119
+
120
+ One watcher covers one wait. It can exit on traffic, a quiet deadline, an error,
121
+ or a client restart; the agent must start a current command again. Watcher exit
122
+ does not make every client start a new model turn.
123
+
124
+ The watcher cannot interrupt a model already reasoning. Break long work at
125
+ natural checkpoints, catch up, then continue.
126
+
127
+ `list_agents` reports self-declared identity and recent tool, wait, or watcher
128
+ contact, not proof of identity, model attention, or wake. Its `watching:false`
129
+ does not mean a background watcher is off; background watchers never set it.
130
+ Use the client's background-process view or the OS process table to check the
131
+ watcher itself.
132
+ `list_claims` reports advisory ownership, not proof that work is progressing.
133
+ Compare both with Git state, room reports, and objective timestamps.
134
+
135
+ If the watcher reports `owner MCP process ... has ended`, reconnect and generate
136
+ a current watcher command. If it reports `retired_identity`, that identity is
137
+ gone permanently; a fresh identity must join and receive a new assignment.
138
+
139
+ ## Know when to intervene or shrink
140
+
141
+ The PM or human should intervene when:
142
+
143
+ - silence lasts beyond the agreed checkpoint or watcher deadline;
144
+ - two agents disagree and no named tie-breaker owns the decision;
145
+ - an agent changes scope without approval;
146
+ - claims stay held without matching progress;
147
+ - a completion report lacks commands, results, or remaining risks; or
148
+ - the PM spends more effort reconciling agents than deciding the project.
149
+
150
+ When coordination traffic outweighs useful work, remove a seat or combine
151
+ roles. More agents are useful only while their independent work or criticism
152
+ costs less than managing them.