multi-agent-collaboration-mcp 0.12.0 → 0.13.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 +227 -138
- package/dist/build-info.json +1 -1
- package/dist/check.js +25 -140
- package/dist/db.js +670 -824
- package/dist/index.js +841 -422
- package/dist/poller.js +188 -81
- package/package.json +2 -5
- package/scripts/stamp-build.mjs +4 -2
- package/web/index.html +19 -39
- package/web/server.mjs +82 -165
- package/scripts/wait-for-updates.sh +0 -39
package/dist/poller.js
CHANGED
|
@@ -9,7 +9,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
const USAGE = `agent-chat-poller: wait for unread work with one SQLite probe every five seconds.
|
|
11
11
|
Usage:
|
|
12
|
-
poller.js --agent <id> [--room <id|name>] [--
|
|
12
|
+
poller.js --agent <id> [--room <id|name>] [--epoch <n>]
|
|
13
13
|
[--owner-pid <pid>]
|
|
14
14
|
[--mentions-only] [--interval <seconds>] [--timeout <seconds>]
|
|
15
15
|
[--ok-on-timeout]
|
|
@@ -17,7 +17,14 @@ Usage:
|
|
|
17
17
|
The interval must be 5..3600 seconds (default 5). The timeout must be
|
|
18
18
|
1..86400 seconds (default 1200). Exit 0 = work exists; with --ok-on-timeout,
|
|
19
19
|
exit 0 also reports a quiet deadline as has_updates:false. Without it,
|
|
20
|
-
timeout exits 124. Exit 2 = invalid arguments, duplicate watcher,
|
|
20
|
+
timeout exits 124. Exit 2 = invalid arguments, duplicate watcher, DB error, or
|
|
21
|
+
a stale binding (the persona was resumed by a newer runtime).
|
|
22
|
+
|
|
23
|
+
--epoch binds this watcher to one runtime tenure of the persona. Every probe
|
|
24
|
+
reads the persona's current epoch; once it moves, this watcher is speaking for
|
|
25
|
+
a runtime that no longer holds the persona, so it exits 2 rather than reporting
|
|
26
|
+
traffic to a seat nobody is sitting in. Omit it only for a diagnostic watch
|
|
27
|
+
that should survive a takeover.
|
|
21
28
|
`;
|
|
22
29
|
function argumentError(message) {
|
|
23
30
|
throw new Error(message);
|
|
@@ -34,7 +41,7 @@ function parseInteger(value, flag, min, max) {
|
|
|
34
41
|
function parseArgs(argv) {
|
|
35
42
|
let agent;
|
|
36
43
|
let room;
|
|
37
|
-
let
|
|
44
|
+
let epoch;
|
|
38
45
|
let ownerPid;
|
|
39
46
|
let db;
|
|
40
47
|
let mentionsOnly = false;
|
|
@@ -60,8 +67,9 @@ function parseArgs(argv) {
|
|
|
60
67
|
agent = take();
|
|
61
68
|
else if (flag === "--room")
|
|
62
69
|
room = take();
|
|
63
|
-
else if (flag === "--
|
|
64
|
-
|
|
70
|
+
else if (flag === "--epoch") {
|
|
71
|
+
epoch = parseInteger(take(), flag, 1, Number.MAX_SAFE_INTEGER);
|
|
72
|
+
}
|
|
65
73
|
else if (flag === "--owner-pid") {
|
|
66
74
|
ownerPid = parseInteger(take(), flag, 1, 2_147_483_647);
|
|
67
75
|
}
|
|
@@ -84,7 +92,7 @@ function parseArgs(argv) {
|
|
|
84
92
|
timeoutOk = true;
|
|
85
93
|
}
|
|
86
94
|
else if (flag === "--since") {
|
|
87
|
-
argumentError("--since is intentionally unsupported: frozen
|
|
95
|
+
argumentError("--since is intentionally unsupported: a frozen baseline re-fires forever once crossed; every probe reads the current read marker instead");
|
|
88
96
|
}
|
|
89
97
|
else if (flag === "--help" || flag === "-h") {
|
|
90
98
|
process.stdout.write(USAGE);
|
|
@@ -100,12 +108,10 @@ function parseArgs(argv) {
|
|
|
100
108
|
argumentError("--agent is too long");
|
|
101
109
|
if (room && room.length > 500)
|
|
102
110
|
argumentError("--room is too long");
|
|
103
|
-
if (session && session.length > 500)
|
|
104
|
-
argumentError("--session is too long");
|
|
105
111
|
return {
|
|
106
112
|
agent,
|
|
107
113
|
room,
|
|
108
|
-
|
|
114
|
+
epoch,
|
|
109
115
|
ownerPid,
|
|
110
116
|
db,
|
|
111
117
|
mentionsOnly,
|
|
@@ -168,19 +174,23 @@ function acquireLock(path, token) {
|
|
|
168
174
|
}
|
|
169
175
|
const sleep = (milliseconds) => new Promise((done) => setTimeout(done, milliseconds));
|
|
170
176
|
async function sleepWhileOwnerAlive(milliseconds, ownerPid) {
|
|
171
|
-
//
|
|
172
|
-
//
|
|
177
|
+
// A watcher with no owner has nothing to check for; let its single timer
|
|
178
|
+
// sleep the whole interval instead of waking every five seconds to ask.
|
|
173
179
|
if (ownerPid === undefined) {
|
|
174
180
|
await sleep(milliseconds);
|
|
175
181
|
return true;
|
|
176
182
|
}
|
|
183
|
+
// An owned watcher subdivides the interval so it notices a dead owner within
|
|
184
|
+
// five seconds rather than at the next probe, which may be an hour away. This
|
|
185
|
+
// is a liveness CHECK of another process, unrelated to the last_seen
|
|
186
|
+
// heartbeat this watcher writes for its own persona.
|
|
177
187
|
const deadline = Date.now() + milliseconds;
|
|
178
188
|
for (;;) {
|
|
179
189
|
const remaining = deadline - Date.now();
|
|
180
190
|
if (remaining <= 0)
|
|
181
191
|
return true;
|
|
182
192
|
await sleep(Math.min(5_000, remaining));
|
|
183
|
-
if (
|
|
193
|
+
if (!processIsAlive(ownerPid))
|
|
184
194
|
return false;
|
|
185
195
|
}
|
|
186
196
|
}
|
|
@@ -234,7 +244,15 @@ try {
|
|
|
234
244
|
const path = realpathSync(requestedPath);
|
|
235
245
|
database = new Database(path);
|
|
236
246
|
database.pragma("busy_timeout = 2000");
|
|
237
|
-
|
|
247
|
+
// A GENERATED runtime watcher -- one this server's wait_for_messages handed
|
|
248
|
+
// out, identified by carrying both --owner-pid and --epoch -- refreshes its
|
|
249
|
+
// persona's last_seen so an armed seat does not read as offline while the
|
|
250
|
+
// model is between turns. Nothing else the watcher does may write, and a
|
|
251
|
+
// watcher without that pair (a hand-run diagnostic watch) stays strictly
|
|
252
|
+
// read-only.
|
|
253
|
+
const heartbeatEnabled = args.ownerPid !== undefined && args.epoch !== undefined;
|
|
254
|
+
if (!heartbeatEnabled)
|
|
255
|
+
database.pragma("query_only = ON");
|
|
238
256
|
let resolvedRoom;
|
|
239
257
|
if (args.room) {
|
|
240
258
|
const numericRoom = /^\d+$/.test(args.room) && Number.isSafeInteger(Number(args.room));
|
|
@@ -248,58 +266,62 @@ try {
|
|
|
248
266
|
if (!resolvedRoom)
|
|
249
267
|
argumentError(`no room "${args.room}"`);
|
|
250
268
|
}
|
|
251
|
-
// A
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// rolling-upgrade guard: old and new pollers must not bypass each other's
|
|
256
|
-
// singleton merely because the lock directory changed.
|
|
269
|
+
// ONE lock directory, per OS user. A shared /tmp/agent-chat-pollers would make
|
|
270
|
+
// the first user to create it the 0700 owner and lock every other user out of
|
|
271
|
+
// running a watcher at all, so POSIX gets a uid suffix. Elsewhere tmpdir() is
|
|
272
|
+
// already per-user.
|
|
257
273
|
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const code = error.code;
|
|
264
|
-
if (uid === null || !["EACCES", "EPERM", "EEXIST"].includes(code ?? "")) {
|
|
265
|
-
throw error;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
const legacyStat = lstatSync(legacyLockDir);
|
|
269
|
-
const lockDirs = [];
|
|
270
|
-
if (legacyStat.isDirectory() && (uid === null || legacyStat.uid === uid)) {
|
|
271
|
-
lockDirs.push(legacyLockDir);
|
|
272
|
-
}
|
|
273
|
-
else if (uid === null) {
|
|
274
|
-
argumentError(`watcher lock path is not a directory: ${legacyLockDir}`);
|
|
274
|
+
const lockDir = join(tmpdir(), uid === null ? "agent-chat-pollers" : `agent-chat-pollers-${uid}`);
|
|
275
|
+
mkdirSync(lockDir, { recursive: true, mode: 0o700 });
|
|
276
|
+
const lockStat = lstatSync(lockDir);
|
|
277
|
+
if (!lockStat.isDirectory()) {
|
|
278
|
+
argumentError(`watcher lock path is not a directory: ${lockDir}`);
|
|
275
279
|
}
|
|
276
|
-
if (uid !== null) {
|
|
277
|
-
|
|
278
|
-
mkdirSync(uidLockDir, { recursive: true, mode: 0o700 });
|
|
279
|
-
const uidStat = lstatSync(uidLockDir);
|
|
280
|
-
if (!uidStat.isDirectory() || uidStat.uid !== uid) {
|
|
281
|
-
argumentError(`watcher lock directory is owned by another user: ${uidLockDir}`);
|
|
282
|
-
}
|
|
283
|
-
lockDirs.push(uidLockDir);
|
|
280
|
+
if (uid !== null && lockStat.uid !== uid) {
|
|
281
|
+
argumentError(`watcher lock directory is owned by another user: ${lockDir}`);
|
|
284
282
|
}
|
|
283
|
+
// The epoch is PART of the singleton key. Two watchers of the same persona
|
|
284
|
+
// and room at different epochs are not duplicates: the older one belongs to a
|
|
285
|
+
// runtime that has already been fenced out and will exit on its next probe.
|
|
286
|
+
// Keying without it made a freshly-resumed runtime unable to arm a watcher
|
|
287
|
+
// until the dying one noticed, leaving the new seat deaf for up to one
|
|
288
|
+
// interval at exactly the moment it most needed to hear.
|
|
285
289
|
const scopeKey = JSON.stringify([
|
|
286
290
|
path,
|
|
287
291
|
args.agent,
|
|
288
292
|
resolvedRoom?.id ?? null,
|
|
289
|
-
args.
|
|
293
|
+
args.epoch ?? null,
|
|
290
294
|
args.mentionsOnly,
|
|
291
295
|
]);
|
|
292
296
|
const lockName = createHash("sha256").update(scopeKey).digest("hex") + ".lock";
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
297
|
+
const lockPath = join(lockDir, lockName);
|
|
298
|
+
// Register before acquiring: a signal delivered immediately after the
|
|
299
|
+
// synchronous create still lets cleanup find the token-owned file.
|
|
300
|
+
lockPaths.push(lockPath);
|
|
301
|
+
acquireLock(lockPath, lockToken);
|
|
302
|
+
const params = { agent: args.agent };
|
|
303
|
+
/**
|
|
304
|
+
* A watcher bound to an epoch stops the moment the persona moves on.
|
|
305
|
+
*
|
|
306
|
+
* The epoch is carried as returned DATA, never as a WHERE predicate. As a
|
|
307
|
+
* predicate it would simply remove the row, and the two probes read a missing
|
|
308
|
+
* row very differently: the scoped one reports "the room was deleted or the
|
|
309
|
+
* membership disappeared", which is a false and actively misleading
|
|
310
|
+
* diagnosis, while the all-rooms one reads it as an ordinary quiet interval
|
|
311
|
+
* and never exits at all. Comparing in JS keeps the true reason attached to
|
|
312
|
+
* the exit.
|
|
313
|
+
*/
|
|
314
|
+
const checkEpoch = (current) => {
|
|
315
|
+
if (args.epoch === undefined)
|
|
316
|
+
return;
|
|
317
|
+
if (current === null) {
|
|
318
|
+
argumentError(`persona "${args.agent}" no longer exists; this watcher's binding is dead`);
|
|
319
|
+
}
|
|
320
|
+
if (current !== args.epoch) {
|
|
321
|
+
argumentError(`stale_binding: persona "${args.agent}" is now at runtime epoch ${current}, ` +
|
|
322
|
+
`this watcher was bound at ${args.epoch}. A newer runtime holds the ` +
|
|
323
|
+
`persona; regenerate the poller command from that runtime.`);
|
|
324
|
+
}
|
|
303
325
|
};
|
|
304
326
|
const directed = args.mentionsOnly
|
|
305
327
|
? ` AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
|
|
@@ -309,31 +331,58 @@ try {
|
|
|
309
331
|
let probe;
|
|
310
332
|
if (resolvedRoom) {
|
|
311
333
|
const membership = database
|
|
312
|
-
.prepare("SELECT
|
|
334
|
+
.prepare("SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
|
|
313
335
|
.get(resolvedRoom.id, args.agent);
|
|
314
336
|
if (!membership) {
|
|
315
337
|
argumentError(`agent "${args.agent}" has not joined room ${resolvedRoom.id}`);
|
|
316
338
|
}
|
|
339
|
+
if (membership.left_at !== null) {
|
|
340
|
+
argumentError(`left_room: agent "${args.agent}" has left room ${resolvedRoom.id}; ` +
|
|
341
|
+
`a watcher on a room you are absent from would deliver traffic peers ` +
|
|
342
|
+
`can see you are not receiving. Rejoin with join_room and use the ` +
|
|
343
|
+
`poller command it returns.`);
|
|
344
|
+
}
|
|
317
345
|
params.room_id = resolvedRoom.id;
|
|
346
|
+
// has_updates, current_epoch and left_at are all COLUMNS of a row this
|
|
347
|
+
// query always returns while the room and membership exist, so "no row"
|
|
348
|
+
// keeps its single unambiguous meaning.
|
|
318
349
|
const statement = database.prepare(`SELECT r.name AS room_name,
|
|
350
|
+
(SELECT a.runtime_epoch FROM agents a WHERE a.id = @agent)
|
|
351
|
+
AS current_epoch,
|
|
352
|
+
mb.left_at AS left_at,
|
|
319
353
|
EXISTS (
|
|
320
354
|
SELECT 1 FROM messages g
|
|
321
355
|
WHERE g.room_id = r.id
|
|
322
|
-
AND g.seq >
|
|
323
|
-
ELSE COALESCE(sm.last_read_seq, mb.last_read_seq) END
|
|
356
|
+
AND g.seq > mb.last_read_seq
|
|
324
357
|
AND g.agent_id != @agent${directed}
|
|
325
358
|
LIMIT 1
|
|
326
359
|
) AS has_updates
|
|
327
360
|
FROM rooms r
|
|
328
361
|
JOIN memberships mb ON mb.room_id = r.id AND mb.agent_id = @agent
|
|
329
|
-
LEFT JOIN session_markers sm ON sm.room_id = r.id
|
|
330
|
-
AND sm.agent_id = mb.agent_id AND sm.session_id = @session
|
|
331
362
|
WHERE r.id = @room_id`);
|
|
332
363
|
probe = () => {
|
|
333
364
|
const row = statement.get(params);
|
|
334
365
|
if (!row) {
|
|
335
366
|
argumentError(`room ${resolvedRoom.id} was deleted or the membership disappeared while watching`);
|
|
336
367
|
}
|
|
368
|
+
checkEpoch(row.current_epoch);
|
|
369
|
+
// Checked on EVERY probe, not just at arm time. A watcher armed while
|
|
370
|
+
// present and left afterwards kept firing invisibly: peers correctly saw
|
|
371
|
+
// the persona absent while its watcher still delivered. left_at rides as
|
|
372
|
+
// returned DATA for the same reason the epoch does -- as a WHERE
|
|
373
|
+
// predicate it would produce "no row", which already means the room or
|
|
374
|
+
// membership is gone, and the two need different diagnostics.
|
|
375
|
+
//
|
|
376
|
+
// This is a DIFFERENT exit from stale_binding. Both say do not re-arm
|
|
377
|
+
// this command, but one means another runtime holds the persona and the
|
|
378
|
+
// other means this persona is not in the room; the next agent should not
|
|
379
|
+
// have to guess which.
|
|
380
|
+
if (row.left_at !== null) {
|
|
381
|
+
argumentError(`left_room: agent "${args.agent}" left room ${resolvedRoom.id} while ` +
|
|
382
|
+
`this watcher was armed; it stops rather than deliver traffic to a ` +
|
|
383
|
+
`room peers can see you are absent from. Rejoin with join_room and ` +
|
|
384
|
+
`use the poller command it returns.`);
|
|
385
|
+
}
|
|
337
386
|
return row.has_updates
|
|
338
387
|
? { room_id: resolvedRoom.id, room_name: row.room_name }
|
|
339
388
|
: undefined;
|
|
@@ -345,33 +394,91 @@ try {
|
|
|
345
394
|
.get(args.agent);
|
|
346
395
|
if (!present)
|
|
347
396
|
argumentError(`agent "${args.agent}" is not present in any room`);
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
397
|
+
// ANCHORED on the persona row, not on memberships. The unread test alone
|
|
398
|
+
// returns ZERO rows on a quiet interval -- the normal case -- so there is no
|
|
399
|
+
// row to hang an epoch column on. Selecting FROM agents makes the result
|
|
400
|
+
// exactly one row whenever the persona exists, carrying the epoch every
|
|
401
|
+
// time and the hit only when there is one. A vanished persona yields no row,
|
|
402
|
+
// which checkEpoch reads as a dead binding rather than as silence.
|
|
403
|
+
const statement = database.prepare(`SELECT a.runtime_epoch AS current_epoch,
|
|
404
|
+
(SELECT mb.room_id FROM memberships mb
|
|
405
|
+
WHERE mb.agent_id = @agent AND mb.left_at IS NULL
|
|
406
|
+
AND EXISTS (
|
|
407
|
+
SELECT 1 FROM messages g
|
|
408
|
+
WHERE g.room_id = mb.room_id
|
|
409
|
+
AND g.seq > mb.last_read_seq
|
|
410
|
+
AND g.agent_id != @agent${directed}
|
|
411
|
+
LIMIT 1
|
|
412
|
+
)
|
|
413
|
+
LIMIT 1) AS room_id
|
|
414
|
+
FROM agents a WHERE a.id = @agent`);
|
|
415
|
+
const roomName = database.prepare("SELECT name FROM rooms WHERE id = ?");
|
|
416
|
+
probe = () => {
|
|
417
|
+
const row = statement.get(params);
|
|
418
|
+
checkEpoch(row ? row.current_epoch : null);
|
|
419
|
+
if (!row || row.room_id === null)
|
|
420
|
+
return undefined;
|
|
421
|
+
// Name lookup only on a HIT, so the quiet path stays one statement.
|
|
422
|
+
const named = roomName.get(row.room_id);
|
|
423
|
+
if (!named)
|
|
424
|
+
return undefined; // deleted between the two reads
|
|
425
|
+
return { room_id: row.room_id, room_name: named.name };
|
|
426
|
+
};
|
|
369
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Liveness heartbeat for a generated runtime watcher.
|
|
430
|
+
*
|
|
431
|
+
* ONE prepared, epoch-fenced UPDATE. The EXISTS clause is the fence: a watcher
|
|
432
|
+
* whose persona has since been resumed elsewhere matches no row, so a stale
|
|
433
|
+
* runtime cannot refresh liveness even in the interval before its next probe
|
|
434
|
+
* notices and exits. It sets last_seen and nothing else -- left_at is never
|
|
435
|
+
* cleared, so this cannot resurrect a seat that left the room. Failure is
|
|
436
|
+
* swallowed: a watcher that cannot write must still report traffic.
|
|
437
|
+
*
|
|
438
|
+
* Cost is one write per two minutes per armed watcher against five-second
|
|
439
|
+
* reads. Its contention impact is UNMEASURED.
|
|
440
|
+
*/
|
|
441
|
+
const HEARTBEAT_INTERVAL_MS = 120_000;
|
|
442
|
+
// SCOPE MATTERS. A watcher with --room listens to exactly ONE room, so it may
|
|
443
|
+
// only refresh THAT room. Refreshing every present membership from a scoped
|
|
444
|
+
// watcher tells peers in rooms nobody is watching that this persona is
|
|
445
|
+
// listening there, which is the same false availability the rest of this
|
|
446
|
+
// work exists to remove. An unscoped watcher does watch every present room,
|
|
447
|
+
// so it refreshes them all.
|
|
448
|
+
const heartbeatScope = resolvedRoom ? " AND room_id = @room_id" : "";
|
|
449
|
+
const heartbeatStatement = heartbeatEnabled
|
|
450
|
+
? database.prepare(`UPDATE memberships SET last_seen = datetime('now')
|
|
451
|
+
WHERE agent_id = @agent AND left_at IS NULL${heartbeatScope}
|
|
452
|
+
AND EXISTS (SELECT 1 FROM agents a
|
|
453
|
+
WHERE a.id = @agent AND a.runtime_epoch = @epoch)`)
|
|
454
|
+
: null;
|
|
455
|
+
let lastHeartbeatMs = 0;
|
|
456
|
+
const heartbeat = () => {
|
|
457
|
+
if (!heartbeatStatement)
|
|
458
|
+
return;
|
|
459
|
+
const now = Date.now();
|
|
460
|
+
if (lastHeartbeatMs !== 0 && now - lastHeartbeatMs < HEARTBEAT_INTERVAL_MS) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Stamp before running, so a failing write throttles like a succeeding one
|
|
464
|
+
// instead of retrying every probe.
|
|
465
|
+
lastHeartbeatMs = now;
|
|
466
|
+
try {
|
|
467
|
+
heartbeatStatement.run({
|
|
468
|
+
agent: args.agent,
|
|
469
|
+
epoch: args.epoch,
|
|
470
|
+
...(resolvedRoom ? { room_id: resolvedRoom.id } : {}),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
catch { }
|
|
474
|
+
};
|
|
475
|
+
heartbeat();
|
|
370
476
|
const deadline = Date.now() + args.timeoutSeconds * 1000;
|
|
371
477
|
for (;;) {
|
|
372
478
|
if (args.ownerPid !== undefined && !processIsAlive(args.ownerPid)) {
|
|
373
479
|
argumentError(`owner MCP process ${args.ownerPid} has ended; regenerate the poller command`);
|
|
374
480
|
}
|
|
481
|
+
heartbeat();
|
|
375
482
|
// Probe before deciding that the deadline is quiet. The final sleep lands
|
|
376
483
|
// on the deadline; checking time first discarded messages that arrived
|
|
377
484
|
// during that last interval and reported a false timeout.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multi-agent-collaboration-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "MCP server: a shared chat room where AI agents coordinate via a SQLite-backed message log",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"type": "module",
|
|
13
13
|
"bin": {
|
|
14
14
|
"multi-agent-collaboration-mcp": "dist/index.js",
|
|
15
|
-
"agent-chat-mcp": "dist/index.js",
|
|
16
15
|
"agent-chat-check": "dist/check.js",
|
|
17
16
|
"agent-chat-poller": "dist/poller.js"
|
|
18
17
|
},
|
|
@@ -30,9 +29,7 @@
|
|
|
30
29
|
"web": "node web/server.mjs",
|
|
31
30
|
"prepare": "node scripts/prepare.mjs",
|
|
32
31
|
"pretest": "node scripts/prepare.mjs",
|
|
33
|
-
"test": "node test/run-suite.mjs"
|
|
34
|
-
"pretest:concurrency": "node scripts/prepare.mjs",
|
|
35
|
-
"test:concurrency": "node test/concurrent-catchup.mjs"
|
|
32
|
+
"test": "node test/run-suite.mjs"
|
|
36
33
|
},
|
|
37
34
|
"engines": {
|
|
38
35
|
"node": ">=22"
|
package/scripts/stamp-build.mjs
CHANGED
|
@@ -33,8 +33,10 @@ try {
|
|
|
33
33
|
|
|
34
34
|
// A timestamp alone makes every no-change rebuild tell already-running MCP
|
|
35
35
|
// processes they are stale. Hash the executable JS instead: reconnect only
|
|
36
|
-
// when the deployed behavior actually differs.
|
|
37
|
-
// diagnostics and
|
|
36
|
+
// when the deployed behavior actually differs. built_at is kept for human
|
|
37
|
+
// diagnostics and ordering ONLY -- it is never compared to decide staleness,
|
|
38
|
+
// and there is no fallback for a stamp without a hash: every stamp this script
|
|
39
|
+
// writes carries one.
|
|
38
40
|
const dist = join(root, "dist");
|
|
39
41
|
const files = [];
|
|
40
42
|
const walk = (dir) => {
|
package/web/index.html
CHANGED
|
@@ -1334,29 +1334,12 @@
|
|
|
1334
1334
|
// identity and room mapping naming different names; and a ghost
|
|
1335
1335
|
// sweep's in-flight leave could evict a membership another tab had
|
|
1336
1336
|
// just re-adopted. Web Locks serialize the whole sequence (awaited
|
|
1337
|
-
// server calls included) across same-origin tabs. localhost is a
|
|
1338
|
-
//
|
|
1339
|
-
// fallback
|
|
1340
|
-
//
|
|
1341
|
-
// Web Locks (older engines; localhost is a secure context so this is
|
|
1342
|
-
// rare) two membership mutations in the SAME tab cannot interleave their
|
|
1343
|
-
// read-modify-write. It does NOT coordinate across tabs -- only Web Locks
|
|
1344
|
-
// do that -- but it removes the within-tab race the old
|
|
1345
|
-
// Promise.resolve().then(fn) left open.
|
|
1346
|
-
let membershipChain = Promise.resolve();
|
|
1337
|
+
// server calls included) across same-origin tabs. localhost is a secure
|
|
1338
|
+
// context, so navigator.locks exists in every engine this viewer runs
|
|
1339
|
+
// in; there is no fallback path, because a same-tab-only fallback would
|
|
1340
|
+
// silently drop the cross-tab guarantee this exists to provide.
|
|
1347
1341
|
function withMembershipLock(fn) {
|
|
1348
|
-
|
|
1349
|
-
return navigator.locks.request("agent-chat.membership", fn);
|
|
1350
|
-
}
|
|
1351
|
-
// membershipChain always fulfills (swallowed below), so fn runs once.
|
|
1352
|
-
const run = membershipChain.then(() => fn());
|
|
1353
|
-
// Keep the chain alive regardless of fn's outcome; swallow here so a
|
|
1354
|
-
// rejection does not poison every later mutation.
|
|
1355
|
-
membershipChain = run.then(
|
|
1356
|
-
() => undefined,
|
|
1357
|
-
() => undefined,
|
|
1358
|
-
);
|
|
1359
|
-
return run;
|
|
1342
|
+
return navigator.locks.request("agent-chat.membership", fn);
|
|
1360
1343
|
}
|
|
1361
1344
|
|
|
1362
1345
|
// Memberships whose soft-leave failed (e.g. server briefly down during
|
|
@@ -1566,9 +1549,14 @@
|
|
|
1566
1549
|
}
|
|
1567
1550
|
|
|
1568
1551
|
function label(m) {
|
|
1569
|
-
//
|
|
1570
|
-
|
|
1571
|
-
|
|
1552
|
+
// A human's name IS the identity. An LLM persona shows its MODEL, which
|
|
1553
|
+
// the schema requires for every LLM row, so there is nothing to fall
|
|
1554
|
+
// back to: a blank label would mean a row the schema says cannot exist,
|
|
1555
|
+
// and quietly substituting the brand or the id would hide that. Its
|
|
1556
|
+
// ROLE is deliberately not used here, because roles are room-local and
|
|
1557
|
+
// mutable, so labelling an old message with the current role
|
|
1558
|
+
// misreports who wrote it.
|
|
1559
|
+
return m.is_human ? m.from : m.model;
|
|
1572
1560
|
}
|
|
1573
1561
|
|
|
1574
1562
|
// Append text with http(s) URLs as clickable links. Everything goes in
|
|
@@ -1598,15 +1586,10 @@
|
|
|
1598
1586
|
const me = identity();
|
|
1599
1587
|
if (!me || m.from === me) return false;
|
|
1600
1588
|
if (Array.isArray(m.mentions) && m.mentions.includes(me)) return true;
|
|
1601
|
-
// The stored reply author decides
|
|
1602
|
-
// needs no parent row loaded
|
|
1603
|
-
//
|
|
1604
|
-
|
|
1605
|
-
if (m.reply_to_seq) {
|
|
1606
|
-
const parent = state.msgs.find((x) => x.seq === m.reply_to_seq);
|
|
1607
|
-
if (parent && parent.from === me) return true;
|
|
1608
|
-
}
|
|
1609
|
-
return false;
|
|
1589
|
+
// The stored reply author decides it outright: it survives parent
|
|
1590
|
+
// pruning and needs no parent row loaded. Both current writers stamp
|
|
1591
|
+
// it, so a reply with no reply_to_agent is simply not directed.
|
|
1592
|
+
return m.reply_to_agent === me;
|
|
1610
1593
|
}
|
|
1611
1594
|
|
|
1612
1595
|
async function fetchJson(url) {
|
|
@@ -1903,11 +1886,8 @@
|
|
|
1903
1886
|
// rendered text is not mistaken for the whole message.
|
|
1904
1887
|
const note = document.createElement("div");
|
|
1905
1888
|
note.className = "trunc-note";
|
|
1906
|
-
// body_length is exact
|
|
1907
|
-
//
|
|
1908
|
-
// a codepoint count, which can undershoot the shown length for
|
|
1909
|
-
// astral-heavy text. Guard against emitting an impossible
|
|
1910
|
-
// "showing X of fewer-than-X" notice in that case.
|
|
1889
|
+
// body_length is exact and in the same UTF-16 unit as body.length,
|
|
1890
|
+
// so the comparison below is apples to apples.
|
|
1911
1891
|
const shown = m.body.length;
|
|
1912
1892
|
const total = Number(m.body_length);
|
|
1913
1893
|
note.textContent =
|