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/web/server.mjs ADDED
@@ -0,0 +1,1016 @@
1
+ // Web viewer + human participation for the agent-chat SQLite database.
2
+ //
3
+ // Standalone ESM script (no build step): it opens the same file the MCP
4
+ // servers write to and serves one HTML page plus JSON endpoints. Reads use a
5
+ // query_only handle; participation (join/post/read/leave) uses a separate
6
+ // writable handle whose message insert mirrors ChatStore.postMessage's
7
+ // IMMEDIATE-transaction seq allocation, so web posts are safe against
8
+ // concurrent agent writers. No auth, bound to localhost: identity is
9
+ // self-asserted by design, exactly like the agents themselves.
10
+ //
11
+ // Run: node web/server.mjs (or: npm run web)
12
+ // Port: AGENT_CHAT_VIEWER_PORT (default 8787)
13
+ // DB: AGENT_CHAT_DB (default ~/.agent-chat-mcp/chat.db)
14
+
15
+ import { createServer } from "node:http";
16
+ import { readFileSync, existsSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join, dirname, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import Database from "better-sqlite3";
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+ // PORT=0 is meaningful (bind an ephemeral port; used by tests), so a falsy
24
+ // check must not swallow it. An explicitly SET but invalid value is a user
25
+ // error and must fail loudly: silently defaulting served the viewer on a
26
+ // port the operator does not expect, and out-of-range values crashed
27
+ // listen() with a stack trace instead of a diagnosis.
28
+ const rawPort = process.env.AGENT_CHAT_VIEWER_PORT;
29
+ let PORT = 8787;
30
+ if (rawPort !== undefined && rawPort.trim() !== "") {
31
+ const p = Number(rawPort.trim());
32
+ if (!Number.isInteger(p) || p < 0 || p > 65535) {
33
+ console.error(
34
+ `agent-chat viewer: AGENT_CHAT_VIEWER_PORT must be an integer 0-65535, got "${rawPort}"`,
35
+ );
36
+ process.exit(1);
37
+ }
38
+ PORT = p;
39
+ }
40
+
41
+ // Same resolution the server uses (kept in sync deliberately, not imported, so
42
+ // the viewer needs no build output and never runs migrations against the file).
43
+ // Absolute always: a relative override means a different file per cwd.
44
+ function resolveDbPath() {
45
+ const override = process.env.AGENT_CHAT_DB;
46
+ if (override && override.trim().length > 0) {
47
+ const t = override.trim();
48
+ // Only the ":memory:" sentinel is special; URI parsing is not enabled.
49
+ if (t === ":memory:") return t;
50
+ return resolve(t);
51
+ }
52
+ return join(homedir(), ".agent-chat-mcp", "chat.db");
53
+ }
54
+ const DB_PATH = resolveDbPath();
55
+ if (DB_PATH === ":memory:") {
56
+ // An in-memory database is process-private: this viewer would open its own
57
+ // fresh empty one, silently unrelated to whatever wrote the sentinel.
58
+ console.error(
59
+ "agent-chat viewer: cannot attach to a :memory: database (it is private " +
60
+ "to the process that opened it)",
61
+ );
62
+ process.exit(1);
63
+ }
64
+
65
+ // Structures this viewer's queries depend on. The viewer never migrates the
66
+ // shared file (that is the MCP server's job); on an older-schema database its
67
+ // queries used to fail as a mix of opaque 400/500s, so check ONCE per open
68
+ // attempt and report the real remedy instead.
69
+ function schemaGaps(d) {
70
+ const gaps = [];
71
+ const col = (t, c) =>
72
+ !!d
73
+ .prepare(`SELECT 1 FROM pragma_table_info('${t}') WHERE name = '${c}'`)
74
+ .get();
75
+ const table = (t) =>
76
+ !!d
77
+ .prepare("SELECT 1 FROM sqlite_master WHERE type IN ('table','trigger') AND name = ?")
78
+ .get(t);
79
+ for (const t of ["rooms", "agents", "memberships", "messages", "session_markers", "session_presence", "claims", "wait_leases"]) {
80
+ if (!table(t)) gaps.push(`table ${t}`);
81
+ }
82
+ if (gaps.length) return gaps; // column checks would all fail anyway
83
+ for (const [t, c] of [
84
+ ["rooms", "pinned"],
85
+ ["memberships", "left_at"],
86
+ ["messages", "supersedes_seq"],
87
+ ["messages", "reply_to_agent"],
88
+ ["messages", "body_len"],
89
+ ["messages", "priority"],
90
+ ]) {
91
+ if (!col(t, c)) gaps.push(`${t}.${c}`);
92
+ }
93
+ if (!table("messages_fts")) gaps.push("table messages_fts");
94
+ return gaps;
95
+ }
96
+ let schemaError = null; // sticky reason string when the file is too old
97
+
98
+ // Open lazily so the server still starts (and says why it is empty) when no
99
+ // agent has created the database yet. Opened writable but pinned query_only: a
100
+ // read-only OPEN of a WAL database is fragile (it needs the -shm file), whereas
101
+ // a writable handle with query_only reads WAL cleanly and still rejects writes.
102
+ let db = null;
103
+ function getDb() {
104
+ if (db) return db;
105
+ if (!existsSync(DB_PATH)) return null;
106
+ const candidate = new Database(DB_PATH, { fileMustExist: true });
107
+ candidate.pragma("busy_timeout = 5000");
108
+ candidate.pragma("query_only = ON");
109
+ const gaps = schemaGaps(candidate);
110
+ if (gaps.length) {
111
+ // Re-checked on every request (cheap PRAGMAs, no cached handle): an MCP
112
+ // server may migrate the file at any moment, after which the viewer
113
+ // starts working without a restart.
114
+ candidate.close();
115
+ schemaError =
116
+ `database schema predates this viewer (missing ${gaps.join(", ")}); ` +
117
+ "start the current agent-chat MCP server once to migrate it";
118
+ return null;
119
+ }
120
+ schemaError = null;
121
+ db = candidate;
122
+ return db;
123
+ }
124
+
125
+ // The sidebar's 30-second refresh: it shows name/activity/presence only, so this
126
+ // carries NO pinned and only a SHORT description snippet (for the filter box).
127
+ // The full pinned/description are fetched by /api/room when a room is opened.
128
+ // Dropping the pinned from this list is what keeps 1000 rooms with 10k intros
129
+ // from producing a ~24 MB response every refresh. Only the most-recently-active
130
+ // ROOMS_MAX are listed. Exact message
131
+ // counts are deliberately absent: recounting history for decorative UI text is
132
+ // recurring work with no bearing on room selection.
133
+ const ROOM_DESC_SNIPPET = 200;
134
+ const ROOMS_MAX = 1000;
135
+ function listRooms() {
136
+ const d = getDb();
137
+ if (!d) return null;
138
+ const rooms = d
139
+ .prepare(
140
+ `SELECT r.id, r.name,
141
+ substr(r.description, 1, ${ROOM_DESC_SNIPPET}) AS description,
142
+ (SELECT COUNT(*) FROM memberships m WHERE m.room_id = r.id AND m.left_at IS NULL) AS members,
143
+ (SELECT created_at FROM messages g WHERE g.room_id = r.id
144
+ ORDER BY seq DESC LIMIT 1) AS last_activity
145
+ FROM rooms r ORDER BY last_activity DESC, r.id DESC LIMIT ${ROOMS_MAX}`,
146
+ )
147
+ .all();
148
+ return { rooms };
149
+ }
150
+
151
+ // Full detail for ONE room (name, description, and the WHOLE pinned intro),
152
+ // fetched when a room is opened -- the sidebar list omits the pinned. Returns
153
+ // null when the room does not exist. Deletion confirmation uses the lean
154
+ // /api/room-exists probe so it never re-fetches a large pinned document.
155
+ function getRoomDetail(roomId) {
156
+ const d = getDb();
157
+ if (!d) return null;
158
+ return (
159
+ d
160
+ .prepare(
161
+ `SELECT r.id, r.name, r.description, r.pinned,
162
+ (SELECT COUNT(*) FROM memberships m
163
+ WHERE m.room_id = r.id AND m.left_at IS NULL) AS members,
164
+ (SELECT created_at FROM messages g WHERE g.room_id = r.id
165
+ ORDER BY seq DESC LIMIT 1) AS last_activity
166
+ FROM rooms r WHERE r.id = ?`,
167
+ )
168
+ .get(roomId) ?? null
169
+ );
170
+ }
171
+
172
+ function roomExists(roomId) {
173
+ const d = getDb();
174
+ if (!d) return null;
175
+ return !!d.prepare("SELECT 1 FROM rooms WHERE id = ?").get(roomId);
176
+ }
177
+
178
+ // Message columns for the JSON endpoints. Bodies are capped in SQL (substr
179
+ // is codepoint-aware, no surrogate splitting): agents can legally post up to
180
+ // SQLITE_MAX_LENGTH, and one such message must not balloon a page into
181
+ // gigabytes. The full length rides along (body_len when stamped: exact UTF-16,
182
+ // the same unit as the JS-side shown length) so the client can label the cut.
183
+ // reply_to_agent lets the client style replies-to-me without needing the
184
+ // parent row loaded.
185
+ const MAX_BODY_CHARS = 100_000; // matches the agents' per-page read budget
186
+
187
+ const MSG_COLS = `g.seq, g.agent_id AS "from", a.role, a.type,
188
+ substr(g.body, 1, ${MAX_BODY_CHARS}) AS body,
189
+ CASE WHEN length(g.body) > ${MAX_BODY_CHARS}
190
+ THEN COALESCE(g.body_len, length(g.body)) ELSE NULL END AS body_length,
191
+ g.format, g.priority,
192
+ g.mentions, g.reply_to_seq, g.reply_to_agent, g.supersedes_seq,
193
+ g.created_at AS at,
194
+ (SELECT s.seq FROM messages s
195
+ WHERE s.room_id = g.room_id AND s.supersedes_seq = g.seq
196
+ ORDER BY s.seq DESC LIMIT 1) AS superseded_by`;
197
+
198
+ // Aggregate SERIALIZED budget per response: the per-row cap alone let a 400-row
199
+ // page of legal 100k bodies serialize to ~40 MB, and counting only body.length
200
+ // still undercounted control-heavy rows ~6x (20 rows of ~2M body units
201
+ // serialized to ~12 MB). Measure the ACTUAL serialized size of each row, after
202
+ // its mentions have been parsed and the body-cap flag applied, so the JSON the
203
+ // client receives is what is bounded. Collection stops (with at least one row,
204
+ // so paging always progresses) once the running total passes this; `trimmed`
205
+ // tells the client the page is short for SIZE, not because history ran out.
206
+ const PAGE_BODY_BUDGET = 2_000_000;
207
+
208
+ // Pull rows off a better-sqlite3 iterator, finalizing each (mentions + body
209
+ // cap) so its measured size matches the wire, until the serialized budget is
210
+ // spent. Measured in UTF-8 BYTES (Buffer.byteLength), the actual wire unit:
211
+ // JSON.stringify(x).length counts UTF-16 units, which undercounts multibyte
212
+ // (e.g. CJK) content ~3x and let a "2 MB" budget serialize to ~6 MB.
213
+ function takeBudgeted(iter) {
214
+ const rows = [];
215
+ let used = 2; // the array's own brackets (ASCII, 2 bytes)
216
+ let trimmed = false;
217
+ for (const r of iter) {
218
+ finalizeRow(r);
219
+ const size = Buffer.byteLength(JSON.stringify(r)) + (rows.length > 0 ? 1 : 0);
220
+ if (rows.length > 0 && used + size > PAGE_BODY_BUDGET) {
221
+ trimmed = true;
222
+ break;
223
+ }
224
+ used += size;
225
+ rows.push(r);
226
+ }
227
+ return { rows, trimmed };
228
+ }
229
+
230
+ // Parse the mentions JSON and apply the body-cap flag for one row. Done BEFORE
231
+ // measuring in takeBudgeted so the measured size is the real serialized size.
232
+ function finalizeRow(r) {
233
+ r.priority = r.priority === 1;
234
+ if (r.mentions) {
235
+ try {
236
+ r.mentions = JSON.parse(r.mentions);
237
+ } catch {
238
+ r.mentions = null;
239
+ }
240
+ }
241
+ finishBodyCap(r);
242
+ }
243
+
244
+ function listMessages(roomId, afterSeq, beforeSeq, limit) {
245
+ const d = getDb();
246
+ if (!d) return null;
247
+ const src = `messages g LEFT JOIN agents a ON a.id = g.agent_id`;
248
+ let taken;
249
+ if (afterSeq > 0) {
250
+ // Incremental tail: only messages newer than what the client already has.
251
+ taken = takeBudgeted(
252
+ d
253
+ .prepare(
254
+ `SELECT ${MSG_COLS} FROM ${src}
255
+ WHERE g.room_id = ? AND g.seq > ? ORDER BY g.seq ASC LIMIT ?`,
256
+ )
257
+ .iterate(roomId, afterSeq, limit),
258
+ );
259
+ } else if (beforeSeq > 0) {
260
+ // History paging: the `limit` messages just older than what is shown.
261
+ taken = takeBudgeted(
262
+ d
263
+ .prepare(
264
+ `SELECT ${MSG_COLS} FROM ${src}
265
+ WHERE g.room_id = ? AND g.seq < ? ORDER BY g.seq DESC LIMIT ?`,
266
+ )
267
+ .iterate(roomId, beforeSeq, limit),
268
+ );
269
+ taken.rows.reverse();
270
+ } else {
271
+ // Initial load: newest `limit`, returned oldest-first for top-to-bottom reading.
272
+ taken = takeBudgeted(
273
+ d
274
+ .prepare(
275
+ `SELECT ${MSG_COLS} FROM ${src}
276
+ WHERE g.room_id = ? ORDER BY g.seq DESC LIMIT ?`,
277
+ )
278
+ .iterate(roomId, limit),
279
+ );
280
+ taken.rows.reverse();
281
+ }
282
+ // Rows are already finalized (mentions parsed, body-cap flag) inside
283
+ // takeBudgeted so its size measurement matched the wire.
284
+ return taken;
285
+ }
286
+
287
+ // Expose the SQL-side body cap as a flag the client can render. The decision
288
+ // is made in SQL (both sides in codepoints); body_length is non-NULL exactly
289
+ // when the body was cut, and carries the full character count.
290
+ function finishBodyCap(r) {
291
+ if (r.body_length != null) {
292
+ r.body_truncated = true;
293
+ } else {
294
+ delete r.body_length;
295
+ }
296
+ }
297
+
298
+ // Writable handle for participation endpoints only. Kept separate from the
299
+ // query_only read handle so a bug in a read path can never write. The schema
300
+ // preflight in getDb() has already gated this: the write paths can assume
301
+ // every current column exists.
302
+ let wdb = null;
303
+ function getWriteDb() {
304
+ if (wdb) return wdb;
305
+ if (!getDb()) return null; // absent file or stale schema (schemaError set)
306
+ wdb = new Database(DB_PATH, { fileMustExist: true });
307
+ wdb.pragma("busy_timeout = 5000");
308
+ // Enforce foreign keys on the write handle, matching the MCP store. The
309
+ // participation paths already assume it: deleteRoomFull deletes messages
310
+ // (and other room-referencing rows) BEFORE the room row precisely "so
311
+ // foreign keys to rooms(id) are satisfied", and postMessage/joinRoom rely on
312
+ // referenced rooms/agents existing. Enabling it turns any future write that
313
+ // forgets those invariants into a clean error instead of a silent orphan.
314
+ wdb.pragma("foreign_keys = ON");
315
+ return wdb;
316
+ }
317
+
318
+ /** Why the database is unusable right now (message for a 503). */
319
+ function dbUnavailableError() {
320
+ return (
321
+ schemaError ??
322
+ `No database at ${DB_PATH}. Start an agent-chat MCP server first.`
323
+ );
324
+ }
325
+
326
+ const NAME_RE = /^[\w][\w.-]{0,199}$/; // sane self-asserted ids, no whitespace
327
+
328
+ // session_presence id for a web participant. Derived from the name (the
329
+ // browser has no process nonce, and all tabs share the localStorage identity
330
+ // anyway); the "web:" prefix cannot collide with the MCP servers' UUID nonces.
331
+ // Web joins MUST register presence: recomputeMembershipPresence treats an
332
+ // identity with ANY presence rows as session-managed, so a web join without a
333
+ // row was evicted (memberships.left_at set) as soon as an MCP session of the
334
+ // same name left or aged out of the GC window.
335
+ function webSession(name) {
336
+ return "web:" + name;
337
+ }
338
+
339
+ // Return a human reason if `s` holds text SQLite cannot round-trip (an
340
+ // embedded NUL -- substr/length truncate at it -- or a lone surrogate), else
341
+ // "". Mirrors the store's assertStorable so a web post is rejected the same way
342
+ // an agent's would be.
343
+ const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
344
+ function badChar(s) {
345
+ if (s.indexOf("\u0000") !== -1) {
346
+ return "contains a NUL character (U+0000), which cannot be stored safely";
347
+ }
348
+ if (LONE_SURROGATE.test(s)) return "contains a lone surrogate (malformed UTF-16)";
349
+ return "";
350
+ }
351
+
352
+ // Cap in BYTES. Sized so a maximal legal post (MAX_BODY_CHARS UTF-16 units,
353
+ // worst case ~3 UTF-8 bytes per unit, plus JSON envelope) still fits.
354
+ function readBody(req, cap = 700_000) {
355
+ return new Promise((resolve, reject) => {
356
+ let size = 0;
357
+ const chunks = [];
358
+ req.on("data", (c) => {
359
+ size += c.length;
360
+ if (size > cap) {
361
+ // Pause, never destroy here: destroying tears down the socket the
362
+ // RESPONSE shares, so the 413 the handler sends would never reach
363
+ // the client (it saw a bare connection reset instead).
364
+ req.pause();
365
+ const err = new Error("request body too large");
366
+ err.tooLarge = true;
367
+ reject(err);
368
+ return;
369
+ }
370
+ chunks.push(c);
371
+ });
372
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
373
+ req.on("error", reject);
374
+ });
375
+ }
376
+
377
+ function membership(d, roomId, name) {
378
+ return d
379
+ .prepare(
380
+ "SELECT left_at FROM memberships WHERE room_id = ? AND agent_id = ?",
381
+ )
382
+ .get(roomId, name);
383
+ }
384
+
385
+ // A web session is joined only when the identity's membership is present AND
386
+ // THIS web session's presence row exists and is live. Deliberately NO
387
+ // fallback for a missing row: treating "no web row" as joined would let any
388
+ // MCP-joined identity post or mark read through the web API without ever
389
+ // joining here, and a left web session could act again the moment an MCP
390
+ // twin held the membership present (advancing the monotonic read marker over
391
+ // unseen messages, or resurrecting the left presence). Web participation
392
+ // from before presence rows existed (pre-v0.8.4) fails this check once and
393
+ // is fixed by rejoining. Gates /api/post, /api/read, and /api/me.
394
+ function webJoined(d, roomId, name) {
395
+ const m = membership(d, roomId, name);
396
+ if (!m || m.left_at !== null) return false;
397
+ const row = d
398
+ .prepare(
399
+ "SELECT left_at FROM session_presence WHERE room_id = ? AND agent_id = ? AND session_id = ?",
400
+ )
401
+ .get(roomId, name, webSession(name));
402
+ return !!row && row.left_at === null;
403
+ }
404
+
405
+ // { error } results become 400s; { status: ... } results become 200s.
406
+ function joinRoom(d, roomId, name) {
407
+ // One IMMEDIATE transaction: with the room-exists check outside it, a
408
+ // concurrent agent delete_room between statements surfaced as an uncaught
409
+ // FK throw (a 500) and left a stray agents row behind.
410
+ const tx = d.transaction(() => {
411
+ const room = d.prepare("SELECT id FROM rooms WHERE id = ?").get(roomId);
412
+ if (!room) return { error: `no room ${roomId}` };
413
+ // Never overwrite an existing agent's type/role: identity is self-asserted,
414
+ // and a human deliberately resuming an agent id keeps that id's metadata.
415
+ d.prepare(
416
+ "INSERT INTO agents (id, type) VALUES (?, 'human') ON CONFLICT(id) DO NOTHING",
417
+ ).run(name);
418
+ d.prepare(
419
+ "INSERT OR IGNORE INTO memberships (room_id, agent_id) VALUES (?, ?)",
420
+ ).run(roomId, name);
421
+ d.prepare(
422
+ "UPDATE memberships SET left_at = NULL, last_seen = datetime('now') WHERE room_id = ? AND agent_id = ?",
423
+ ).run(roomId, name);
424
+ // Register/refresh this web participant's presence row (see webSession).
425
+ d.prepare(
426
+ `INSERT INTO session_presence (room_id, agent_id, session_id)
427
+ VALUES (?, ?, ?)
428
+ ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
429
+ updated_at = datetime('now'), left_at = NULL`,
430
+ ).run(roomId, name, webSession(name));
431
+ const m = d
432
+ .prepare(
433
+ "SELECT last_read_seq FROM memberships WHERE room_id = ? AND agent_id = ?",
434
+ )
435
+ .get(roomId, name);
436
+ return {
437
+ joined: true,
438
+ agent_id: name,
439
+ room_id: roomId,
440
+ last_read_seq: m.last_read_seq,
441
+ };
442
+ });
443
+ try {
444
+ return tx.immediate();
445
+ } catch (e) {
446
+ return { error: String((e && e.message) || e) };
447
+ }
448
+ }
449
+
450
+ function postMessage(d, roomId, name, body, replyToSeq, mentions) {
451
+ const mentionsJson =
452
+ mentions && mentions.length > 0 ? JSON.stringify(mentions) : null;
453
+ // Same shape as ChatStore.postMessage: validate membership and the reply
454
+ // target and allocate the next per-room seq inside one IMMEDIATE
455
+ // transaction, so a concurrent agent writer cannot take the same seq, the
456
+ // reply reference cannot dangle against a racing prune, and a concurrent
457
+ // delete_room yields this clean error instead of a raw FK failure.
458
+ const tx = d.transaction(() => {
459
+ if (!webJoined(d, roomId, name)) {
460
+ throw new Error("join the room first (POST /api/join)");
461
+ }
462
+ let replyToAgent = null;
463
+ if (replyToSeq !== null) {
464
+ const parent = d
465
+ .prepare("SELECT agent_id FROM messages WHERE room_id = ? AND seq = ?")
466
+ .get(roomId, replyToSeq);
467
+ if (!parent) {
468
+ throw new Error(`reply_to_seq ${replyToSeq} does not exist in this room`);
469
+ }
470
+ replyToAgent = parent.agent_id;
471
+ }
472
+ const { last_read_seq: from } = d
473
+ .prepare(
474
+ "SELECT last_read_seq FROM memberships WHERE room_id = ? AND agent_id = ?",
475
+ )
476
+ .get(roomId, name);
477
+ // A backward "latest peer" search becomes quadratic when one unread peer is
478
+ // followed by a growing self tail: every post re-walks that tail. Probe
479
+ // forward instead, stopping at the first peer. If one exists, conservatively
480
+ // normalize no cursor here; catch_up will deliver it and repair own gaps. If
481
+ // none exists, `from` is a safe floor for the shared cursor and private
482
+ // siblings at or beyond it. One history probe, never one per sibling.
483
+ const unreadPeer = d
484
+ .prepare(
485
+ `SELECT 1 FROM messages
486
+ WHERE room_id = ? AND seq > ? AND agent_id != ?
487
+ ORDER BY seq ASC LIMIT 1`,
488
+ )
489
+ .get(roomId, from, name);
490
+ const canNormalize = unreadPeer ? 0 : 1;
491
+ const { next } = d
492
+ .prepare(
493
+ "SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM messages WHERE room_id = ?",
494
+ )
495
+ .get(roomId);
496
+ // body_len is the exact UTF-16 length (same stamp ChatStore.postMessage
497
+ // writes); the schema preflight guarantees both columns exist.
498
+ d.prepare(
499
+ `INSERT INTO messages (room_id, seq, agent_id, format, body, body_len, mentions, reply_to_seq, reply_to_agent)
500
+ VALUES (?, ?, ?, 'text', ?, ?, ?, ?, ?)`,
501
+ ).run(roomId, next, name, body, body.length, mentionsJson, replyToSeq, replyToAgent);
502
+ // Own rows are excluded from unread delivery. Move only cursors at/after the
503
+ // proven safe floor through this row; a lagging peer remains unread.
504
+ d.prepare(
505
+ `UPDATE memberships
506
+ SET last_read_seq = CASE WHEN ? = 1 AND last_read_seq >= ?
507
+ THEN max(last_read_seq, ?) ELSE last_read_seq END,
508
+ last_seen = datetime('now')
509
+ WHERE room_id = ? AND agent_id = ?`,
510
+ ).run(canNormalize, from, next, roomId, name);
511
+ if (canNormalize === 1) {
512
+ d.prepare(
513
+ `UPDATE session_markers
514
+ SET last_read_seq = max(last_read_seq, ?)
515
+ WHERE room_id = ? AND agent_id = ? AND last_read_seq >= ?`,
516
+ ).run(next, roomId, name, from);
517
+ }
518
+ // Posting is active participation: re-assert this web session's presence
519
+ // (recreating a row the 7-day GC reaped), matching the MCP store's touch().
520
+ d.prepare(
521
+ `INSERT INTO session_presence (room_id, agent_id, session_id)
522
+ VALUES (?, ?, ?)
523
+ ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
524
+ updated_at = datetime('now'), left_at = NULL`,
525
+ ).run(roomId, name, webSession(name));
526
+ return next;
527
+ });
528
+ try {
529
+ return { seq: tx.immediate() };
530
+ } catch (e) {
531
+ return { error: String((e && e.message) || e) };
532
+ }
533
+ }
534
+
535
+ function markRead(d, roomId, name, seq) {
536
+ // One IMMEDIATE transaction (read-then-write), so a concurrent room
537
+ // deletion cannot vanish the membership row between statements.
538
+ const tx = d.transaction(() => {
539
+ // Gate on the live WEB session, not bare membership: a read landing after
540
+ // this web session left (a stale tab, an in-flight auto-mark) advanced
541
+ // the monotonic durable marker over messages nobody had seen.
542
+ if (!webJoined(d, roomId, name)) {
543
+ return { error: "join the room first (POST /api/join)" };
544
+ }
545
+ // Clamp to the room's latest seq (parity with the MCP server's mark_read):
546
+ // the monotonic max() below makes an unclamped over-large value permanent,
547
+ // wedging the marker above every future message.
548
+ const { latest } = d
549
+ .prepare(
550
+ "SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?",
551
+ )
552
+ .get(roomId);
553
+ const eff = Math.min(seq, latest);
554
+ // Monotonic BY DESIGN for the web viewer, unlike MCP mark_read (which
555
+ // supports deliberate rewind): the browser auto-marks from async
556
+ // completions, so a delayed stale write must never regress the marker.
557
+ d.prepare(
558
+ `UPDATE memberships SET last_read_seq = max(last_read_seq, ?), last_seen = datetime('now')
559
+ WHERE room_id = ? AND agent_id = ?`,
560
+ ).run(eff, roomId, name);
561
+ // Keep a live web session's presence row fresh against the 7-day GC.
562
+ // REFRESH only (no upsert): the browser auto-marks read, and that must
563
+ // not resurrect a presence row for a room the participant left.
564
+ d.prepare(
565
+ `UPDATE session_presence SET updated_at = datetime('now')
566
+ WHERE room_id = ? AND agent_id = ? AND session_id = ? AND left_at IS NULL`,
567
+ ).run(roomId, name, webSession(name));
568
+ const row = d
569
+ .prepare(
570
+ "SELECT last_read_seq FROM memberships WHERE room_id = ? AND agent_id = ?",
571
+ )
572
+ .get(roomId, name);
573
+ return { last_read_seq: row ? row.last_read_seq : eff };
574
+ });
575
+ try {
576
+ return tx.immediate();
577
+ } catch (e) {
578
+ return { error: String((e && e.message) || e) };
579
+ }
580
+ }
581
+
582
+ // Full room deletion, mirroring ChatStore.deleteRoom: messages first (so the
583
+ // FTS delete-trigger fires), then memberships, session markers/presence,
584
+ // wait leases, claims, and the room row, in one IMMEDIATE transaction.
585
+ // Unauthenticated by design, exactly like the MCP delete_room tool.
586
+ function deleteRoomFull(d, roomId) {
587
+ const tx = d.transaction(() => {
588
+ const room = d.prepare("SELECT name FROM rooms WHERE id = ?").get(roomId);
589
+ if (!room) return { error: `no room ${roomId}` };
590
+ const { c: messages } = d
591
+ .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
592
+ .get(roomId);
593
+ const { c: members } = d
594
+ .prepare("SELECT COUNT(*) AS c FROM memberships WHERE room_id = ?")
595
+ .get(roomId);
596
+ d.prepare("DELETE FROM messages WHERE room_id = ?").run(roomId);
597
+ d.prepare("DELETE FROM memberships WHERE room_id = ?").run(roomId);
598
+ d.prepare("DELETE FROM session_markers WHERE room_id = ?").run(roomId);
599
+ d.prepare("DELETE FROM session_presence WHERE room_id = ?").run(roomId);
600
+ // v0.10 added a rooms(id) FK from wait_leases. Omitting it made a live or
601
+ // lingering blocking wait turn confirmed web deletion into a rolled-back
602
+ // FOREIGN KEY failure; the store's deleteRoom already clears this table.
603
+ d.prepare("DELETE FROM wait_leases WHERE room_id = ?").run(roomId);
604
+ d.prepare("DELETE FROM claims WHERE room_id = ?").run(roomId);
605
+ d.prepare("DELETE FROM rooms WHERE id = ?").run(roomId);
606
+ return { deleted_room: roomId, name: room.name, messages, members };
607
+ });
608
+ return tx.immediate();
609
+ }
610
+
611
+ // Session-aware leave, mirroring ChatStore.leaveRoom: mark THIS web session's
612
+ // presence row left, then recompute the identity flag from the surviving live
613
+ // rows -- present iff any session (web or MCP) is still live. Unconditionally
614
+ // setting memberships.left_at evicted a live MCP twin running under the same
615
+ // name. The '-7 days' liveness window matches the store's SESSION_GC_AGE.
616
+ function leaveRoom(d, roomId, name) {
617
+ const tx = d.transaction(() => {
618
+ const s = d
619
+ .prepare(
620
+ `UPDATE session_presence SET left_at = datetime('now')
621
+ WHERE room_id = ? AND agent_id = ? AND session_id = ? AND left_at IS NULL`,
622
+ )
623
+ .run(roomId, name, webSession(name));
624
+ const live = d
625
+ .prepare(
626
+ `SELECT 1 FROM session_presence
627
+ WHERE room_id = ? AND agent_id = ? AND left_at IS NULL
628
+ AND updated_at >= datetime('now', '-7 days') LIMIT 1`,
629
+ )
630
+ .get(roomId, name);
631
+ // `left` matches the store's semantics: true iff THIS session went
632
+ // present -> left (or, with no presence rows at all, the identity did).
633
+ let left = s.changes > 0;
634
+ if (!live) {
635
+ const info = d
636
+ .prepare(
637
+ `UPDATE memberships SET left_at = datetime('now'), last_seen = datetime('now')
638
+ WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`,
639
+ )
640
+ .run(roomId, name);
641
+ left = left || info.changes > 0;
642
+ }
643
+ return { left, room_id: roomId };
644
+ });
645
+ try {
646
+ return tx.immediate();
647
+ } catch (e) {
648
+ return { error: String((e && e.message) || e) };
649
+ }
650
+ }
651
+
652
+ // Mentions are parsed server-side from @tokens so every client gets the same
653
+ // semantics; ids are stored as tagged even if that agent never joined,
654
+ // matching how agent mentions behave. Trailing dots/dashes are stripped:
655
+ // "ask @bob." tags bob, not "bob.".
656
+ function parseMentions(body) {
657
+ const out = [];
658
+ // Capture up to 201 chars, strip trailing punctuation, THEN length-check:
659
+ // capping the regex at 200 made a 201-char id silently tag its first-200
660
+ // prefix, a nonexistent identity.
661
+ for (const m of body.matchAll(/@([\w][\w.-]{0,200})/g)) {
662
+ const id = m[1].replace(/[.-]+$/, "");
663
+ if (!id || id.length > 200 || out.includes(id)) continue;
664
+ out.push(id);
665
+ if (out.length >= 100) break;
666
+ }
667
+ return out;
668
+ }
669
+
670
+ // Full-text search over the room's messages via the FTS index the MCP server
671
+ // maintains; best matches first. FTS5 syntax errors surface to the caller.
672
+ function searchMessages(roomId, q, limit) {
673
+ const d = getDb();
674
+ if (!d) return null;
675
+ // rank, g.id: total, stable order so a size-trimmed page is deterministic.
676
+ // Fetch one MORE than asked (parity with the MCP search): a page of exactly
677
+ // `limit` matches otherwise carried no "more exist" signal at all -- the
678
+ // trimmed flag only fired on a byte cut.
679
+ const taken = takeBudgeted(
680
+ d
681
+ .prepare(
682
+ `SELECT ${MSG_COLS}
683
+ FROM messages_fts f
684
+ JOIN messages g ON g.id = f.rowid
685
+ LEFT JOIN agents a ON a.id = g.agent_id
686
+ WHERE f.body MATCH ? AND g.room_id = ?
687
+ ORDER BY rank, g.id LIMIT ?`,
688
+ )
689
+ .iterate(q, roomId, limit + 1),
690
+ );
691
+ if (taken.rows.length > limit) {
692
+ taken.rows.length = limit;
693
+ taken.trimmed = true;
694
+ }
695
+ // Rows already finalized inside takeBudgeted.
696
+ return taken;
697
+ }
698
+
699
+ function sendJson(res, status, payload) {
700
+ res.writeHead(status, {
701
+ "Content-Type": "application/json; charset=utf-8",
702
+ "Cache-Control": "no-store",
703
+ });
704
+ res.end(JSON.stringify(payload));
705
+ }
706
+
707
+ async function handlePost(url, req, res) {
708
+ // A foreign web page can fire no-preflight POSTs at localhost from the
709
+ // operator's browser; require the request's own EXACT origin (scheme +
710
+ // host + port). Hostname-only checking let any other localhost-bound
711
+ // process's page (a dev server on another port) write here. Requests
712
+ // without an Origin header (curl, scripts) stay allowed: local processes
713
+ // can already write the database file directly, so this is browser-context
714
+ // hygiene, not authentication. The Host header was allowlisted upstream.
715
+ const origin = req.headers.origin;
716
+ if (
717
+ origin &&
718
+ origin.toLowerCase() !== `http://${(req.headers.host || "").toLowerCase()}`
719
+ ) {
720
+ return sendJson(res, 403, { error: "cross-origin writes are not allowed" });
721
+ }
722
+ const d = getWriteDb();
723
+ if (!d) {
724
+ return sendJson(res, 503, { error: dbUnavailableError() });
725
+ }
726
+ let payload;
727
+ try {
728
+ payload = JSON.parse(await readBody(req));
729
+ } catch (e) {
730
+ const tooLarge = !!(e && e.tooLarge);
731
+ res.writeHead(tooLarge ? 413 : 400, {
732
+ "Content-Type": "application/json; charset=utf-8",
733
+ "Cache-Control": "no-store",
734
+ Connection: "close",
735
+ });
736
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
737
+ // Drop the half-received request only after the response has flushed.
738
+ res.on("finish", () => req.destroy());
739
+ return;
740
+ }
741
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
742
+ return sendJson(res, 400, { error: "request body must be a JSON object" });
743
+ }
744
+ // Require a real JSON number: Number() coercion accepted true/[1]/"1"
745
+ // (all of which coerce to a usable integer) despite the error text below.
746
+ const roomId =
747
+ typeof payload.room === "number" && Number.isSafeInteger(payload.room)
748
+ ? payload.room
749
+ : NaN;
750
+ if (!Number.isSafeInteger(roomId) || roomId <= 0) {
751
+ return sendJson(res, 400, {
752
+ error: "room must be a positive safe integer id",
753
+ });
754
+ }
755
+
756
+ if (url.pathname === "/api/delete-room") {
757
+ // No identity required (parity with the MCP delete_room tool), but the
758
+ // same explicit confirm gate: destructive and irreversible.
759
+ if (payload.confirm !== true) {
760
+ return sendJson(res, 400, {
761
+ error:
762
+ "pass confirm:true to permanently delete the room and ALL of its " +
763
+ "messages, memberships, and claims (irreversible)",
764
+ });
765
+ }
766
+ const r = deleteRoomFull(d, roomId);
767
+ return sendJson(res, r.error ? 400 : 200, r);
768
+ }
769
+
770
+ const name = typeof payload.name === "string" ? payload.name.trim() : "";
771
+ if (!NAME_RE.test(name)) {
772
+ return sendJson(res, 400, {
773
+ error:
774
+ "name must be 1-200 chars: letters, digits, underscore, dot or dash (no spaces)",
775
+ });
776
+ }
777
+
778
+ if (url.pathname === "/api/join") {
779
+ const r = joinRoom(d, roomId, name);
780
+ return sendJson(res, r.error ? 400 : 200, r);
781
+ }
782
+ if (url.pathname === "/api/leave") {
783
+ const r = leaveRoom(d, roomId, name);
784
+ return sendJson(res, r.error ? 400 : 200, r);
785
+ }
786
+ if (url.pathname === "/api/read") {
787
+ const seq =
788
+ typeof payload.seq === "number" && Number.isSafeInteger(payload.seq)
789
+ ? payload.seq
790
+ : NaN;
791
+ if (!Number.isSafeInteger(seq) || seq < 0) {
792
+ return sendJson(res, 400, {
793
+ error: "seq must be a non-negative safe integer",
794
+ });
795
+ }
796
+ const r = markRead(d, roomId, name, seq);
797
+ return sendJson(res, r.error ? 400 : 200, r);
798
+ }
799
+ if (url.pathname === "/api/post") {
800
+ const body = typeof payload.body === "string" ? payload.body.trim() : "";
801
+ if (!body) return sendJson(res, 400, { error: "message body is empty" });
802
+ if (body.length > MAX_BODY_CHARS) {
803
+ return sendJson(res, 400, {
804
+ error: `message exceeds ${MAX_BODY_CHARS} chars`,
805
+ });
806
+ }
807
+ // Same well-formedness gate as the MCP store (the web writes SQL directly,
808
+ // so it must enforce this itself): SQLite substr/length stop at a NUL, and
809
+ // a lone surrogate is renormalized, so either would read back corrupt.
810
+ const bad = badChar(body);
811
+ if (bad) return sendJson(res, 400, { error: `message body ${bad}` });
812
+ let replyTo = null;
813
+ if (payload.reply_to_seq !== undefined && payload.reply_to_seq !== null) {
814
+ replyTo =
815
+ typeof payload.reply_to_seq === "number" &&
816
+ Number.isSafeInteger(payload.reply_to_seq)
817
+ ? payload.reply_to_seq
818
+ : NaN;
819
+ if (!Number.isSafeInteger(replyTo) || replyTo <= 0) {
820
+ return sendJson(res, 400, {
821
+ error: "reply_to_seq must be a positive safe integer",
822
+ });
823
+ }
824
+ }
825
+ const r = postMessage(d, roomId, name, body, replyTo, parseMentions(body));
826
+ return sendJson(res, r.error ? 400 : 200, r);
827
+ }
828
+ res.writeHead(404, { "Content-Type": "text/plain" });
829
+ res.end("not found");
830
+ }
831
+
832
+ const server = createServer(async (req, res) => {
833
+ // DNS-rebinding hygiene on EVERY request (reads included): a hostile page
834
+ // whose hostname rebinds to 127.0.0.1 issues requests the browser treats
835
+ // as same-origin, exfiltrating chat content via the GET endpoints. The
836
+ // Host header still carries the page's own hostname through a rebind, so
837
+ // requiring a local one closes that door. Same class as the Origin gate
838
+ // on writes: browser-context hygiene, not authentication.
839
+ const rawHost = (req.headers.host || "").toLowerCase();
840
+ const hostname = rawHost.startsWith("[")
841
+ ? rawHost.slice(0, rawHost.indexOf("]") + 1)
842
+ : rawHost.split(":")[0];
843
+ if (hostname !== "127.0.0.1" && hostname !== "localhost" && hostname !== "[::1]") {
844
+ return sendJson(res, 403, { error: "unrecognized Host header" });
845
+ }
846
+ let url;
847
+ try {
848
+ url = new URL(req.url, `http://${req.headers.host}`);
849
+ } catch {
850
+ res.writeHead(400);
851
+ res.end("bad request");
852
+ return;
853
+ }
854
+ try {
855
+ if (req.method === "POST" && url.pathname.startsWith("/api/")) {
856
+ return await handlePost(url, req, res);
857
+ }
858
+ if (url.pathname === "/" || url.pathname === "/index.html") {
859
+ // Read BEFORE writeHead: a read failure after headers are sent would
860
+ // make the catch's second writeHead throw and crash the process.
861
+ const html = readFileSync(join(HERE, "index.html"));
862
+ res.writeHead(200, {
863
+ "Content-Type": "text/html; charset=utf-8",
864
+ // The page carries destructive controls (room deletion); refuse to
865
+ // render inside any frame so a hostile page cannot clickjack them.
866
+ "X-Frame-Options": "DENY",
867
+ "Content-Security-Policy": "frame-ancestors 'none'",
868
+ });
869
+ res.end(html);
870
+ return;
871
+ }
872
+ if (url.pathname === "/api/rooms") {
873
+ const result = listRooms();
874
+ if (result === null)
875
+ return sendJson(res, 200, {
876
+ rooms: [],
877
+ error: dbUnavailableError(),
878
+ });
879
+ return sendJson(res, 200, { rooms: result.rooms });
880
+ }
881
+ if (url.pathname === "/api/room") {
882
+ // Full detail for one room (name, description, WHOLE pinned), fetched on
883
+ // open. Recurring deletion checks use /api/room-exists below.
884
+ const roomId = Number(url.searchParams.get("id"));
885
+ if (!Number.isSafeInteger(roomId) || roomId <= 0)
886
+ return sendJson(res, 400, {
887
+ error: "id must be a positive safe integer",
888
+ });
889
+ const d = getDb();
890
+ if (!d) return sendJson(res, 200, { room: null, error: dbUnavailableError() });
891
+ return sendJson(res, 200, { room: getRoomDetail(roomId) });
892
+ }
893
+ if (url.pathname === "/api/room-exists") {
894
+ // Deletion confirmation for an open room displaced from the capped room
895
+ // list. Keep this a one-row existence probe: /api/room may carry a large
896
+ // pinned document and should only be fetched when a room is opened.
897
+ const roomId = Number(url.searchParams.get("id"));
898
+ if (!Number.isSafeInteger(roomId) || roomId <= 0)
899
+ return sendJson(res, 400, {
900
+ error: "id must be a positive safe integer",
901
+ });
902
+ const exists = roomExists(roomId);
903
+ if (exists === null)
904
+ return sendJson(res, 200, { exists: null, error: dbUnavailableError() });
905
+ return sendJson(res, 200, { exists });
906
+ }
907
+ if (url.pathname === "/api/messages") {
908
+ const roomId = Number(url.searchParams.get("room"));
909
+ if (!Number.isSafeInteger(roomId) || roomId <= 0)
910
+ return sendJson(res, 400, {
911
+ error: "room must be a positive safe integer",
912
+ });
913
+ const after = Number(url.searchParams.get("after") ?? 0);
914
+ const before = Number(url.searchParams.get("before") ?? 0);
915
+ if (
916
+ !Number.isSafeInteger(after) ||
917
+ after < 0 ||
918
+ !Number.isSafeInteger(before) ||
919
+ before < 0
920
+ ) {
921
+ return sendJson(res, 400, {
922
+ error: "after/before must be non-negative safe integers",
923
+ });
924
+ }
925
+ // Floor only LIMIT: floats used to reach SQLite and trigger a 500, and
926
+ // limit is a page-size knob rather than a durable sequence identity.
927
+ const limit = Math.max(
928
+ 1,
929
+ Math.min(Math.floor(Number(url.searchParams.get("limit"))) || 200, 1000),
930
+ );
931
+ const result = listMessages(roomId, after, before, limit);
932
+ if (result === null)
933
+ return sendJson(res, 200, {
934
+ messages: [],
935
+ error: dbUnavailableError(),
936
+ });
937
+ return sendJson(res, 200, {
938
+ messages: result.rows,
939
+ // Short page because of the SIZE budget, not exhausted history; the
940
+ // client must not conclude "no more messages" from it.
941
+ ...(result.trimmed ? { trimmed: true } : {}),
942
+ });
943
+ }
944
+ if (url.pathname === "/api/me") {
945
+ // Membership state for a (room, name): lets the client learn its read
946
+ // marker after a reload so gap-aware read marking works.
947
+ const roomId = Number(url.searchParams.get("room"));
948
+ const name = (url.searchParams.get("name") || "").trim();
949
+ if (!Number.isSafeInteger(roomId) || roomId <= 0 || !name)
950
+ return sendJson(res, 400, { error: "room (id) and name are required" });
951
+ const d = getDb();
952
+ if (!d)
953
+ // Distinguish "database not ready" (absent file / stale schema) from a
954
+ // genuine not-joined: reporting joined:false on a stale schema hid the
955
+ // real remedy and made gap logic silently wrong.
956
+ return sendJson(res, 200, { joined: false, error: dbUnavailableError() });
957
+ const m = d
958
+ .prepare(
959
+ "SELECT last_read_seq, left_at FROM memberships WHERE room_id = ? AND agent_id = ?",
960
+ )
961
+ .get(roomId, name);
962
+ return sendJson(res, 200, {
963
+ // Same gate as post/read: joined means THIS web session, so the
964
+ // client never believes it can act on the strength of an MCP twin's
965
+ // membership.
966
+ joined: webJoined(d, roomId, name),
967
+ last_read_seq: m ? m.last_read_seq : 0,
968
+ });
969
+ }
970
+ if (url.pathname === "/api/search") {
971
+ const roomId = Number(url.searchParams.get("room"));
972
+ if (!Number.isSafeInteger(roomId) || roomId <= 0)
973
+ return sendJson(res, 400, {
974
+ error: "room must be a positive safe integer",
975
+ });
976
+ const q = (url.searchParams.get("q") || "").trim();
977
+ if (!q) return sendJson(res, 400, { error: "q is required" });
978
+ const limit = Math.max(
979
+ 1,
980
+ Math.min(Math.floor(Number(url.searchParams.get("limit"))) || 30, 100),
981
+ );
982
+ try {
983
+ const result = searchMessages(roomId, q, limit);
984
+ if (result === null)
985
+ return sendJson(res, 200, {
986
+ matches: [],
987
+ error: dbUnavailableError(),
988
+ });
989
+ return sendJson(res, 200, {
990
+ matches: result.rows,
991
+ q,
992
+ ...(result.trimmed ? { trimmed: true } : {}),
993
+ });
994
+ } catch (e) {
995
+ // Most commonly an FTS5 syntax error in q; a 400 the UI can display.
996
+ return sendJson(res, 400, { error: String((e && e.message) || e) });
997
+ }
998
+ }
999
+ res.writeHead(404, { "Content-Type": "text/plain" });
1000
+ res.end("not found");
1001
+ } catch (err) {
1002
+ if (res.headersSent) {
1003
+ res.destroy();
1004
+ return;
1005
+ }
1006
+ sendJson(res, 500, { error: String((err && err.message) || err) });
1007
+ }
1008
+ });
1009
+
1010
+ server.listen(PORT, "127.0.0.1", () => {
1011
+ // Report the real bound port so PORT=0 (ephemeral, used by tests) works.
1012
+ const port = server.address().port;
1013
+ const present = existsSync(DB_PATH) ? "" : " (not found yet)";
1014
+ console.log(`agent-chat viewer: http://127.0.0.1:${port}`);
1015
+ console.log(`database: ${DB_PATH}${present}`);
1016
+ });