multi-agent-collaboration-mcp 0.12.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/LICENSE +202 -0
- package/README.md +217 -0
- package/dist/bounded-lines.js +78 -0
- package/dist/build-info.json +1 -0
- package/dist/check.js +428 -0
- package/dist/db.js +3102 -0
- package/dist/index.js +2241 -0
- package/dist/poller.js +419 -0
- package/dist/unicode.js +78 -0
- package/package.json +51 -0
- package/scripts/prepare.mjs +82 -0
- package/scripts/refresh-mcp.sh +228 -0
- package/scripts/stamp-build.mjs +93 -0
- package/scripts/wait-for-updates.sh +39 -0
- package/web/index.html +3117 -0
- package/web/server.mjs +1016 -0
package/dist/poller.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One bounded watcher: hold one SQLite connection and run one indexed LIMIT 1
|
|
3
|
+
// probe after each sleep. No shell loop, subprocesses, temp output, counts, or
|
|
4
|
+
// grouping. A hit identifies one room; catch_up does the actual read.
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
const USAGE = `agent-chat-poller: wait for unread work with one SQLite probe every five seconds.
|
|
11
|
+
Usage:
|
|
12
|
+
poller.js --agent <id> [--room <id|name>] [--session <nonce>]
|
|
13
|
+
[--owner-pid <pid>]
|
|
14
|
+
[--mentions-only] [--interval <seconds>] [--timeout <seconds>]
|
|
15
|
+
[--ok-on-timeout]
|
|
16
|
+
|
|
17
|
+
The interval must be 5..3600 seconds (default 5). The timeout must be
|
|
18
|
+
1..86400 seconds (default 1200). Exit 0 = work exists; with --ok-on-timeout,
|
|
19
|
+
exit 0 also reports a quiet deadline as has_updates:false. Without it,
|
|
20
|
+
timeout exits 124. Exit 2 = invalid arguments, duplicate watcher, or DB error.
|
|
21
|
+
`;
|
|
22
|
+
function argumentError(message) {
|
|
23
|
+
throw new Error(message);
|
|
24
|
+
}
|
|
25
|
+
function parseInteger(value, flag, min, max) {
|
|
26
|
+
if (!/^\d+$/.test(value))
|
|
27
|
+
argumentError(`${flag} must be an integer`);
|
|
28
|
+
const parsed = Number(value);
|
|
29
|
+
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
|
|
30
|
+
argumentError(`${flag} must be between ${min} and ${max}`);
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
let agent;
|
|
36
|
+
let room;
|
|
37
|
+
let session;
|
|
38
|
+
let ownerPid;
|
|
39
|
+
let db;
|
|
40
|
+
let mentionsOnly = false;
|
|
41
|
+
let intervalSeconds = 5;
|
|
42
|
+
let timeoutSeconds = 1200;
|
|
43
|
+
let timeoutOk = false;
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
let flag = argv[i];
|
|
46
|
+
let inline;
|
|
47
|
+
const eq = flag.indexOf("=");
|
|
48
|
+
if (eq !== -1) {
|
|
49
|
+
inline = flag.slice(eq + 1);
|
|
50
|
+
flag = flag.slice(0, eq);
|
|
51
|
+
}
|
|
52
|
+
const take = () => {
|
|
53
|
+
const value = inline ?? argv[++i];
|
|
54
|
+
if (value === undefined || value.trim() === "") {
|
|
55
|
+
argumentError(`${flag} requires a non-empty value`);
|
|
56
|
+
}
|
|
57
|
+
return value.trim();
|
|
58
|
+
};
|
|
59
|
+
if (flag === "--agent")
|
|
60
|
+
agent = take();
|
|
61
|
+
else if (flag === "--room")
|
|
62
|
+
room = take();
|
|
63
|
+
else if (flag === "--session")
|
|
64
|
+
session = take();
|
|
65
|
+
else if (flag === "--owner-pid") {
|
|
66
|
+
ownerPid = parseInteger(take(), flag, 1, 2_147_483_647);
|
|
67
|
+
}
|
|
68
|
+
else if (flag === "--db")
|
|
69
|
+
db = take();
|
|
70
|
+
else if (flag === "--interval") {
|
|
71
|
+
intervalSeconds = parseInteger(take(), flag, 5, 3600);
|
|
72
|
+
}
|
|
73
|
+
else if (flag === "--timeout") {
|
|
74
|
+
timeoutSeconds = parseInteger(take(), flag, 1, 86_400);
|
|
75
|
+
}
|
|
76
|
+
else if (flag === "--mentions-only") {
|
|
77
|
+
if (inline !== undefined)
|
|
78
|
+
argumentError(`${flag} takes no value`);
|
|
79
|
+
mentionsOnly = true;
|
|
80
|
+
}
|
|
81
|
+
else if (flag === "--ok-on-timeout") {
|
|
82
|
+
if (inline !== undefined)
|
|
83
|
+
argumentError(`${flag} takes no value`);
|
|
84
|
+
timeoutOk = true;
|
|
85
|
+
}
|
|
86
|
+
else if (flag === "--since") {
|
|
87
|
+
argumentError("--since is intentionally unsupported: frozen baselines can re-fire forever; use --session");
|
|
88
|
+
}
|
|
89
|
+
else if (flag === "--help" || flag === "-h") {
|
|
90
|
+
process.stdout.write(USAGE);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
argumentError(`unknown argument: ${flag}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!agent)
|
|
98
|
+
argumentError("--agent is required");
|
|
99
|
+
if (agent.length > 200)
|
|
100
|
+
argumentError("--agent is too long");
|
|
101
|
+
if (room && room.length > 500)
|
|
102
|
+
argumentError("--room is too long");
|
|
103
|
+
if (session && session.length > 500)
|
|
104
|
+
argumentError("--session is too long");
|
|
105
|
+
return {
|
|
106
|
+
agent,
|
|
107
|
+
room,
|
|
108
|
+
session,
|
|
109
|
+
ownerPid,
|
|
110
|
+
db,
|
|
111
|
+
mentionsOnly,
|
|
112
|
+
intervalSeconds,
|
|
113
|
+
timeoutSeconds,
|
|
114
|
+
timeoutOk,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function dbPath(override) {
|
|
118
|
+
const value = override ?? process.env.AGENT_CHAT_DB;
|
|
119
|
+
if (value && value.trim()) {
|
|
120
|
+
const trimmed = value.trim();
|
|
121
|
+
return trimmed === ":memory:" ? trimmed : resolve(trimmed);
|
|
122
|
+
}
|
|
123
|
+
return join(homedir(), ".agent-chat-mcp", "chat.db");
|
|
124
|
+
}
|
|
125
|
+
function processIsAlive(pid) {
|
|
126
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
127
|
+
return false;
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, 0);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return error.code !== "ESRCH";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function acquireLock(path, token) {
|
|
137
|
+
try {
|
|
138
|
+
const fd = openSync(path, "wx", 0o600);
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, token }));
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(path);
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
closeSync(fd);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error.code !== "EEXIST")
|
|
155
|
+
throw error;
|
|
156
|
+
let owner = 0;
|
|
157
|
+
try {
|
|
158
|
+
owner = Number(JSON.parse(readFileSync(path, "utf8")).pid);
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
if (processIsAlive(owner)) {
|
|
162
|
+
argumentError(`an equivalent watcher is already running (pid ${owner}; lock: ${path})`);
|
|
163
|
+
}
|
|
164
|
+
// Fail closed. Automatic stale-lock stealing needs a second interprocess
|
|
165
|
+
// lock to avoid two reapers deleting each other's replacement.
|
|
166
|
+
argumentError(`stale watcher lock requires removal: ${path}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const sleep = (milliseconds) => new Promise((done) => setTimeout(done, milliseconds));
|
|
170
|
+
async function sleepWhileOwnerAlive(milliseconds, ownerPid) {
|
|
171
|
+
// Independent/manual pollers need no five-second owner heartbeat; let their
|
|
172
|
+
// single timer sleep for the requested interval without needless wakeups.
|
|
173
|
+
if (ownerPid === undefined) {
|
|
174
|
+
await sleep(milliseconds);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
const deadline = Date.now() + milliseconds;
|
|
178
|
+
for (;;) {
|
|
179
|
+
const remaining = deadline - Date.now();
|
|
180
|
+
if (remaining <= 0)
|
|
181
|
+
return true;
|
|
182
|
+
await sleep(Math.min(5_000, remaining));
|
|
183
|
+
if (ownerPid !== undefined && !processIsAlive(ownerPid))
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
let database = null;
|
|
188
|
+
const lockPaths = [];
|
|
189
|
+
const lockToken = randomUUID();
|
|
190
|
+
let cleaned = false;
|
|
191
|
+
function cleanup() {
|
|
192
|
+
if (cleaned)
|
|
193
|
+
return;
|
|
194
|
+
cleaned = true;
|
|
195
|
+
try {
|
|
196
|
+
database?.close();
|
|
197
|
+
}
|
|
198
|
+
catch { }
|
|
199
|
+
for (const lockPath of lockPaths) {
|
|
200
|
+
try {
|
|
201
|
+
const current = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
202
|
+
if (current.token === lockToken)
|
|
203
|
+
unlinkSync(lockPath);
|
|
204
|
+
}
|
|
205
|
+
catch { }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Install lifecycle guards before argument parsing, database opening, and lock
|
|
209
|
+
// acquisition. A SIGHUP or broken output pipe must not strand the fail-closed
|
|
210
|
+
// watcher lock. Cleanup is synchronous/idempotent, so the exit hook also covers
|
|
211
|
+
// ordinary process.exit() calls and exceptions outside the main try/finally.
|
|
212
|
+
function terminate(code) {
|
|
213
|
+
cleanup();
|
|
214
|
+
process.exit(code);
|
|
215
|
+
}
|
|
216
|
+
process.once("exit", cleanup);
|
|
217
|
+
process.once("SIGHUP", () => terminate(129));
|
|
218
|
+
process.once("SIGINT", () => terminate(130));
|
|
219
|
+
process.once("SIGTERM", () => terminate(143));
|
|
220
|
+
process.stdout.once("error", () => terminate(2));
|
|
221
|
+
process.stderr.once("error", () => terminate(2));
|
|
222
|
+
let exitCode = 2;
|
|
223
|
+
try {
|
|
224
|
+
const args = parseArgs(process.argv.slice(2));
|
|
225
|
+
if (args.ownerPid !== undefined && !processIsAlive(args.ownerPid)) {
|
|
226
|
+
argumentError(`owner MCP process ${args.ownerPid} has ended; regenerate the poller command`);
|
|
227
|
+
}
|
|
228
|
+
const requestedPath = dbPath(args.db);
|
|
229
|
+
if (requestedPath === ":memory:" || !existsSync(requestedPath)) {
|
|
230
|
+
argumentError(`database not found: ${requestedPath}`);
|
|
231
|
+
}
|
|
232
|
+
// Canonicalize symlinks before deriving the singleton key, so aliases of
|
|
233
|
+
// the same SQLite file cannot acquire separate watcher locks.
|
|
234
|
+
const path = realpathSync(requestedPath);
|
|
235
|
+
database = new Database(path);
|
|
236
|
+
database.pragma("busy_timeout = 2000");
|
|
237
|
+
database.pragma("query_only = ON");
|
|
238
|
+
let resolvedRoom;
|
|
239
|
+
if (args.room) {
|
|
240
|
+
const numericRoom = /^\d+$/.test(args.room) && Number.isSafeInteger(Number(args.room));
|
|
241
|
+
resolvedRoom = numericRoom
|
|
242
|
+
? database
|
|
243
|
+
.prepare("SELECT id, name FROM rooms WHERE id = ?")
|
|
244
|
+
.get(Number(args.room))
|
|
245
|
+
: database
|
|
246
|
+
.prepare("SELECT id, name FROM rooms WHERE name = ?")
|
|
247
|
+
.get(args.room);
|
|
248
|
+
if (!resolvedRoom)
|
|
249
|
+
argumentError(`no room "${args.room}"`);
|
|
250
|
+
}
|
|
251
|
+
// A global /tmp/agent-chat-pollers directory makes the first OS user its
|
|
252
|
+
// owner (0700) and prevents every other user from starting a watcher. Use a
|
|
253
|
+
// per-UID primary directory on POSIX. When this user owns the legacy
|
|
254
|
+
// directory, acquire the same scoped lock there first as a zero-runtime-cost
|
|
255
|
+
// rolling-upgrade guard: old and new pollers must not bypass each other's
|
|
256
|
+
// singleton merely because the lock directory changed.
|
|
257
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
258
|
+
const legacyLockDir = join(tmpdir(), "agent-chat-pollers");
|
|
259
|
+
try {
|
|
260
|
+
mkdirSync(legacyLockDir, { recursive: true, mode: 0o700 });
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
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}`);
|
|
275
|
+
}
|
|
276
|
+
if (uid !== null) {
|
|
277
|
+
const uidLockDir = join(tmpdir(), `agent-chat-pollers-${uid}`);
|
|
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);
|
|
284
|
+
}
|
|
285
|
+
const scopeKey = JSON.stringify([
|
|
286
|
+
path,
|
|
287
|
+
args.agent,
|
|
288
|
+
resolvedRoom?.id ?? null,
|
|
289
|
+
args.session ?? null,
|
|
290
|
+
args.mentionsOnly,
|
|
291
|
+
]);
|
|
292
|
+
const lockName = createHash("sha256").update(scopeKey).digest("hex") + ".lock";
|
|
293
|
+
for (const lockDir of lockDirs) {
|
|
294
|
+
const lockPath = join(lockDir, lockName);
|
|
295
|
+
// Register before acquiring: a signal delivered immediately after the
|
|
296
|
+
// synchronous create still lets cleanup find the token-owned file.
|
|
297
|
+
lockPaths.push(lockPath);
|
|
298
|
+
acquireLock(lockPath, lockToken);
|
|
299
|
+
}
|
|
300
|
+
const params = {
|
|
301
|
+
agent: args.agent,
|
|
302
|
+
session: args.session ?? "",
|
|
303
|
+
};
|
|
304
|
+
const directed = args.mentionsOnly
|
|
305
|
+
? ` AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
|
|
306
|
+
AND (EXISTS (SELECT 1 FROM json_each(g.mentions) WHERE value = @agent)
|
|
307
|
+
OR g.reply_to_agent = @agent)`
|
|
308
|
+
: "";
|
|
309
|
+
let probe;
|
|
310
|
+
if (resolvedRoom) {
|
|
311
|
+
const membership = database
|
|
312
|
+
.prepare("SELECT 1 FROM memberships WHERE room_id = ? AND agent_id = ?")
|
|
313
|
+
.get(resolvedRoom.id, args.agent);
|
|
314
|
+
if (!membership) {
|
|
315
|
+
argumentError(`agent "${args.agent}" has not joined room ${resolvedRoom.id}`);
|
|
316
|
+
}
|
|
317
|
+
params.room_id = resolvedRoom.id;
|
|
318
|
+
const statement = database.prepare(`SELECT r.name AS room_name,
|
|
319
|
+
EXISTS (
|
|
320
|
+
SELECT 1 FROM messages g
|
|
321
|
+
WHERE g.room_id = r.id
|
|
322
|
+
AND g.seq > CASE WHEN @session = '' THEN mb.last_read_seq
|
|
323
|
+
ELSE COALESCE(sm.last_read_seq, mb.last_read_seq) END
|
|
324
|
+
AND g.agent_id != @agent${directed}
|
|
325
|
+
LIMIT 1
|
|
326
|
+
) AS has_updates
|
|
327
|
+
FROM rooms r
|
|
328
|
+
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
|
+
WHERE r.id = @room_id`);
|
|
332
|
+
probe = () => {
|
|
333
|
+
const row = statement.get(params);
|
|
334
|
+
if (!row) {
|
|
335
|
+
argumentError(`room ${resolvedRoom.id} was deleted or the membership disappeared while watching`);
|
|
336
|
+
}
|
|
337
|
+
return row.has_updates
|
|
338
|
+
? { room_id: resolvedRoom.id, room_name: row.room_name }
|
|
339
|
+
: undefined;
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
const present = database
|
|
344
|
+
.prepare("SELECT 1 FROM memberships WHERE agent_id = ? AND left_at IS NULL LIMIT 1")
|
|
345
|
+
.get(args.agent);
|
|
346
|
+
if (!present)
|
|
347
|
+
argumentError(`agent "${args.agent}" is not present in any room`);
|
|
348
|
+
const statement = database.prepare(`SELECT mb.room_id AS room_id, r.name AS room_name
|
|
349
|
+
FROM memberships mb
|
|
350
|
+
JOIN rooms r ON r.id = mb.room_id
|
|
351
|
+
LEFT JOIN session_markers sm ON sm.room_id = mb.room_id
|
|
352
|
+
AND sm.agent_id = mb.agent_id AND sm.session_id = @session
|
|
353
|
+
WHERE mb.agent_id = @agent AND mb.left_at IS NULL
|
|
354
|
+
AND (@session = '' OR NOT EXISTS (
|
|
355
|
+
SELECT 1 FROM session_presence sp
|
|
356
|
+
WHERE sp.room_id = mb.room_id AND sp.agent_id = mb.agent_id
|
|
357
|
+
AND sp.session_id = @session AND sp.left_at IS NOT NULL
|
|
358
|
+
))
|
|
359
|
+
AND EXISTS (
|
|
360
|
+
SELECT 1 FROM messages g
|
|
361
|
+
WHERE g.room_id = mb.room_id
|
|
362
|
+
AND g.seq > CASE WHEN @session = '' THEN mb.last_read_seq
|
|
363
|
+
ELSE COALESCE(sm.last_read_seq, mb.last_read_seq) END
|
|
364
|
+
AND g.agent_id != @agent${directed}
|
|
365
|
+
LIMIT 1
|
|
366
|
+
)
|
|
367
|
+
LIMIT 1`);
|
|
368
|
+
probe = () => statement.get(params);
|
|
369
|
+
}
|
|
370
|
+
const deadline = Date.now() + args.timeoutSeconds * 1000;
|
|
371
|
+
for (;;) {
|
|
372
|
+
if (args.ownerPid !== undefined && !processIsAlive(args.ownerPid)) {
|
|
373
|
+
argumentError(`owner MCP process ${args.ownerPid} has ended; regenerate the poller command`);
|
|
374
|
+
}
|
|
375
|
+
// Probe before deciding that the deadline is quiet. The final sleep lands
|
|
376
|
+
// on the deadline; checking time first discarded messages that arrived
|
|
377
|
+
// during that last interval and reported a false timeout.
|
|
378
|
+
const hit = probe();
|
|
379
|
+
if (hit) {
|
|
380
|
+
await new Promise((resolveWrite, rejectWrite) => {
|
|
381
|
+
process.stdout.write(JSON.stringify({
|
|
382
|
+
has_updates: true,
|
|
383
|
+
agent: args.agent,
|
|
384
|
+
room_id: hit.room_id,
|
|
385
|
+
room_name: hit.room_name,
|
|
386
|
+
mentions_only: args.mentionsOnly,
|
|
387
|
+
}) + "\n", (error) => (error ? rejectWrite(error) : resolveWrite()));
|
|
388
|
+
});
|
|
389
|
+
exitCode = 0;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
if (Date.now() >= deadline) {
|
|
393
|
+
if (args.timeoutOk) {
|
|
394
|
+
await new Promise((resolveWrite, rejectWrite) => {
|
|
395
|
+
process.stdout.write('{"has_updates":false,"timed_out":true}\n', (error) => (error ? rejectWrite(error) : resolveWrite()));
|
|
396
|
+
});
|
|
397
|
+
exitCode = 0;
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
process.stderr.write('{"timed_out":true}\n');
|
|
401
|
+
exitCode = 124;
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
const remaining = deadline - Date.now();
|
|
406
|
+
const ownerAlive = await sleepWhileOwnerAlive(Math.min(args.intervalSeconds * 1000, remaining), args.ownerPid);
|
|
407
|
+
if (!ownerAlive) {
|
|
408
|
+
argumentError(`owner MCP process ${args.ownerPid} has ended; regenerate the poller command`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
process.stderr.write(`agent-chat-poller: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
414
|
+
exitCode = 2;
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
cleanup();
|
|
418
|
+
}
|
|
419
|
+
process.exitCode = exitCode;
|
package/dist/unicode.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// A high surrogate not immediately followed by a low, or a low not preceded
|
|
2
|
+
// by a high: either is an unpaired (lone) surrogate. JavaScript strings can
|
|
3
|
+
// contain these malformed UTF-16 code units even though they are not valid
|
|
4
|
+
// Unicode scalar values.
|
|
5
|
+
const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
6
|
+
/** Reject a string that contains malformed UTF-16. */
|
|
7
|
+
export function assertWellFormedUtf16(value, field) {
|
|
8
|
+
if (LONE_SURROGATE.test(value)) {
|
|
9
|
+
throw new Error(`${field} contains a lone surrogate (malformed UTF-16); fix the encoding`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Reject a lone surrogate in any string value or object key anywhere in a
|
|
14
|
+
* PARSED JSON value. Used at the store boundary for direct callers of
|
|
15
|
+
* postMessage(format:"json"): their already-serialized body hides a nested lone
|
|
16
|
+
* surrogate as an ASCII "\\uXXXX" escape that a raw-string check cannot see, and
|
|
17
|
+
* JSON.parse reconstructs it for readers. The MCP handler validates earlier and
|
|
18
|
+
* more cheaply via stringifyWellFormedJson (pre-escape, single pass); this is
|
|
19
|
+
* the defense-in-depth walk for everyone else.
|
|
20
|
+
*/
|
|
21
|
+
export function assertWellFormedJsonValue(value, field) {
|
|
22
|
+
if (typeof value === "string") {
|
|
23
|
+
assertWellFormedUtf16(value, `${field} string`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
for (const v of value)
|
|
28
|
+
assertWellFormedJsonValue(v, field);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (value !== null && typeof value === "object") {
|
|
32
|
+
for (const k of Object.keys(value)) {
|
|
33
|
+
assertWellFormedUtf16(k, `${field} object key`);
|
|
34
|
+
assertWellFormedJsonValue(value[k], field);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Serialize structured content while rejecting lone surrogates in every JSON
|
|
40
|
+
* string value and object key. A plain JSON.stringify would escape a lone
|
|
41
|
+
* surrogate to ASCII (for example, "\\ud800"), hiding it from the storage
|
|
42
|
+
* guard; JSON.parse would then recreate the malformed value for readers.
|
|
43
|
+
*
|
|
44
|
+
* The replacer validates values during JSON.stringify's own traversal, before
|
|
45
|
+
* they are escaped. This covers nested objects, arrays, keys, and toJSON output
|
|
46
|
+
* without a second recursive walk or a second in-memory representation.
|
|
47
|
+
* Embedded NUL is deliberately allowed here: JSON escapes it for storage and
|
|
48
|
+
* it is valid Unicode. Only malformed UTF-16 is a reader-interoperability risk.
|
|
49
|
+
*/
|
|
50
|
+
export function stringifyWellFormedJson(value, field) {
|
|
51
|
+
const encoded = JSON.stringify(value, function (key, child) {
|
|
52
|
+
// Array keys are generated decimal indexes and cannot contain malformed
|
|
53
|
+
// UTF-16. Avoid running the regex on every element of a wide array.
|
|
54
|
+
if (!Array.isArray(this)) {
|
|
55
|
+
assertWellFormedUtf16(key, `${field} object key`);
|
|
56
|
+
}
|
|
57
|
+
// JSON.stringify unboxes String objects only AFTER the replacer runs.
|
|
58
|
+
// They cannot arrive over JSON-RPC, but handling them keeps this shared
|
|
59
|
+
// serializer correct for in-process callers and for toJSON return values.
|
|
60
|
+
let stringValue = null;
|
|
61
|
+
if (typeof child === "string") {
|
|
62
|
+
stringValue = child;
|
|
63
|
+
}
|
|
64
|
+
else if (typeof child === "object" &&
|
|
65
|
+
child !== null &&
|
|
66
|
+
child instanceof String) {
|
|
67
|
+
stringValue = String.prototype.valueOf.call(child);
|
|
68
|
+
}
|
|
69
|
+
if (stringValue !== null) {
|
|
70
|
+
assertWellFormedUtf16(stringValue, `${field} string`);
|
|
71
|
+
}
|
|
72
|
+
return child;
|
|
73
|
+
});
|
|
74
|
+
if (encoded === undefined) {
|
|
75
|
+
throw new Error(`${field} is not JSON-serializable`);
|
|
76
|
+
}
|
|
77
|
+
return encoded;
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "multi-agent-collaboration-mcp",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "MCP server: a shared chat room where AI agents coordinate via a SQLite-backed message log",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Alex-R-A/multi-agent-collaboration-mcp.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Alex-R-A/multi-agent-collaboration-mcp#readme",
|
|
11
|
+
"bugs": "https://github.com/Alex-R-A/multi-agent-collaboration-mcp/issues",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"multi-agent-collaboration-mcp": "dist/index.js",
|
|
15
|
+
"agent-chat-mcp": "dist/index.js",
|
|
16
|
+
"agent-chat-check": "dist/check.js",
|
|
17
|
+
"agent-chat-poller": "dist/poller.js"
|
|
18
|
+
},
|
|
19
|
+
"main": "dist/index.js",
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"scripts",
|
|
23
|
+
"web"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc && node scripts/stamp-build.mjs",
|
|
27
|
+
"dev": "tsx src/index.ts",
|
|
28
|
+
"start": "node dist/index.js",
|
|
29
|
+
"mcp:refresh": "node scripts/prepare.mjs && bash scripts/refresh-mcp.sh",
|
|
30
|
+
"web": "node web/server.mjs",
|
|
31
|
+
"prepare": "node scripts/prepare.mjs",
|
|
32
|
+
"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"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
42
|
+
"better-sqlite3": "^11.8.0",
|
|
43
|
+
"zod": "^3.23.8"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
47
|
+
"@types/node": "^22.10.0",
|
|
48
|
+
"tsx": "^4.19.0",
|
|
49
|
+
"typescript": "^5.6.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// npm runs the `prepare` script in two very different places: a git-dependency
|
|
2
|
+
// install (src/ present: build it) and inside an extracted PUBLISHED tarball
|
|
3
|
+
// (src/ and tsconfig.json deliberately not shipped; dist/ already is).
|
|
4
|
+
// Running tsc unconditionally made `npm install` fail inside the tarball.
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
if (!existsSync(new URL("../src", import.meta.url))) {
|
|
10
|
+
process.exit(0); // packaged tarball: dist is prebuilt, nothing to do
|
|
11
|
+
}
|
|
12
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
13
|
+
// Invoke the two build programs directly. `npm run build` adds an npm process
|
|
14
|
+
// and a shell between this watchdog and tsc; killing only that parent on a
|
|
15
|
+
// timeout can leave the compiler running. These direct children do not spawn
|
|
16
|
+
// long-lived descendants (stamp-build's git probes have their own 10s caps).
|
|
17
|
+
const steps = [
|
|
18
|
+
fileURLToPath(new URL("../node_modules/typescript/bin/tsc", import.meta.url)),
|
|
19
|
+
fileURLToPath(new URL("./stamp-build.mjs", import.meta.url)),
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const grouped = process.platform !== "win32";
|
|
23
|
+
let active = null;
|
|
24
|
+
function killActive() {
|
|
25
|
+
if (!active?.pid) return;
|
|
26
|
+
try {
|
|
27
|
+
if (grouped) process.kill(-active.pid, "SIGKILL");
|
|
28
|
+
else active.kill("SIGKILL");
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
function stopFromSignal(code) {
|
|
32
|
+
killActive();
|
|
33
|
+
process.exit(code);
|
|
34
|
+
}
|
|
35
|
+
process.once("SIGHUP", () => stopFromSignal(129));
|
|
36
|
+
process.once("SIGINT", () => stopFromSignal(130));
|
|
37
|
+
process.once("SIGTERM", () => stopFromSignal(143));
|
|
38
|
+
process.once("exit", killActive);
|
|
39
|
+
|
|
40
|
+
async function runStep(script) {
|
|
41
|
+
return await new Promise((resolve) => {
|
|
42
|
+
const child = spawn(process.execPath, [script], {
|
|
43
|
+
stdio: "inherit",
|
|
44
|
+
detached: grouped,
|
|
45
|
+
// prepare.mjs may be invoked by absolute path from any directory. Keep
|
|
46
|
+
// tsc's project discovery inside this repository rather than compiling a
|
|
47
|
+
// caller's unrelated (and potentially huge) working tree.
|
|
48
|
+
cwd: root,
|
|
49
|
+
});
|
|
50
|
+
active = child;
|
|
51
|
+
let timedOut = false;
|
|
52
|
+
let spawnError = null;
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
timedOut = true;
|
|
55
|
+
killActive();
|
|
56
|
+
}, 120_000);
|
|
57
|
+
child.once("error", (error) => {
|
|
58
|
+
spawnError = error;
|
|
59
|
+
killActive();
|
|
60
|
+
});
|
|
61
|
+
child.once("close", (code, signal) => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
killActive();
|
|
64
|
+
active = null;
|
|
65
|
+
resolve({ code, signal, timedOut, spawnError });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const script of steps) {
|
|
71
|
+
const result = await runStep(script);
|
|
72
|
+
if (result.code !== 0 || result.timedOut || result.spawnError) {
|
|
73
|
+
const reason = result.timedOut
|
|
74
|
+
? "timed out after 120000ms"
|
|
75
|
+
: result.spawnError
|
|
76
|
+
? `spawn failed: ${result.spawnError.message}`
|
|
77
|
+
: `exited ${result.code ?? result.signal}`;
|
|
78
|
+
process.stderr.write(`build step ${script} ${reason}\n`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|