agent-comm-hub 0.7.0 → 0.8.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/lib/index.js CHANGED
@@ -422,6 +422,7 @@ import { randomUUID } from "node:crypto";
422
422
  var KINDS = ["chat", "task", "notice", "ack"];
423
423
  var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
424
424
  var BROADCAST = "all";
425
+ var TERMINAL_TASK_STATUSES = ["rejected", "done", "failed"];
425
426
  function encodeContent(payload) {
426
427
  return JSON.stringify(payload);
427
428
  }
@@ -437,6 +438,7 @@ function decodeContent(kind, content) {
437
438
 
438
439
  // src/hub.ts
439
440
  var MAX_PROFILES = 512;
441
+ var MAX_TASKS = 1024;
440
442
  var AgentHub = class {
441
443
  constructor(options) {
442
444
  this.options = options;
@@ -455,6 +457,8 @@ var AgentHub = class {
455
457
  profiles = /* @__PURE__ */ new Map();
456
458
  /** Named channels (group channel), keyed by group id. */
457
459
  groups = /* @__PURE__ */ new Map();
460
+ /** Task ledger: task message id → record (status + ack timeline). */
461
+ taskLedger = /* @__PURE__ */ new Map();
458
462
  gcTimer;
459
463
  /** Stop the idle GC (call when the hub shuts down). */
460
464
  dispose() {
@@ -625,15 +629,92 @@ var AgentHub = class {
625
629
  send(from, to, kind, content) {
626
630
  return this.route(from, to, kind, content);
627
631
  }
628
- /** Send a structured task message to `to` (or {@link BROADCAST}). */
632
+ /** Send a structured task message to `to` (or {@link BROADCAST}). Records
633
+ * a ledger entry so the sender can later query status / wait for acks. */
629
634
  sendTask(from, to, task) {
630
- return this.route(from, to, "task", JSON.stringify(task));
635
+ const message = this.route(from, to, "task", JSON.stringify(task));
636
+ this.recordTask(message, task);
637
+ return message;
631
638
  }
632
- /** Send an acknowledgement back to the sender of `ref`. */
639
+ /** Send an acknowledgement back to the sender of `ref`. Updates the task
640
+ * ledger when `ref` is a known task; chat/notice refs still route. */
633
641
  sendAck(from, ref, ack) {
634
- const original = this.historyRing.findLast((message) => message.id === ref);
642
+ const original = this.historyRing.findLast((message2) => message2.id === ref);
635
643
  if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
636
- return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
644
+ const message = this.route(from, original.from, "ack", JSON.stringify(ack), ref);
645
+ this.applyAckToLedger(message, from, ref, ack);
646
+ return message;
647
+ }
648
+ /** Create or refresh the ledger row for a newly sent task. */
649
+ recordTask(message, task) {
650
+ const now = message.ts;
651
+ this.taskLedger.set(message.id, {
652
+ id: message.id,
653
+ from: message.from,
654
+ to: message.to,
655
+ prompt: task.prompt,
656
+ ...task.context !== void 0 ? { context: task.context } : {},
657
+ ...task.deliverable !== void 0 ? { deliverable: task.deliverable } : {},
658
+ status: "pending",
659
+ acks: [],
660
+ createdAt: now,
661
+ updatedAt: now
662
+ });
663
+ this.evictOverflowTasks();
664
+ }
665
+ /** Fold an ack into the task's timeline and status. Unknown refs are a
666
+ * no-op (chat/notice can be acked for routing without a ledger row). */
667
+ applyAckToLedger(message, from, ref, ack) {
668
+ const record = this.taskLedger.get(ref);
669
+ if (record === void 0) return;
670
+ record.acks.push({
671
+ from,
672
+ status: ack.status,
673
+ ...ack.note !== void 0 ? { note: ack.note } : {},
674
+ ts: message.ts,
675
+ messageId: message.id
676
+ });
677
+ record.status = ack.status;
678
+ record.updatedAt = message.ts;
679
+ }
680
+ /** Keep the ledger bounded: drop oldest terminal tasks first. */
681
+ evictOverflowTasks() {
682
+ if (this.taskLedger.size <= MAX_TASKS) return;
683
+ const evictable = [...this.taskLedger.values()].filter((t) => TERMINAL_TASK_STATUSES.includes(t.status)).sort((a, b) => a.updatedAt - b.updatedAt);
684
+ let overflow = this.taskLedger.size - MAX_TASKS;
685
+ for (const record of evictable) {
686
+ if (overflow <= 0) break;
687
+ this.taskLedger.delete(record.id);
688
+ overflow--;
689
+ }
690
+ }
691
+ /** One task by message id (the ack `ref`). */
692
+ taskOf(ref) {
693
+ const record = this.taskLedger.get(ref);
694
+ return record === void 0 ? void 0 : { ...record, acks: record.acks.map((a) => ({ ...a })) };
695
+ }
696
+ /**
697
+ * Tasks involving `peer`. `role`:
698
+ * - `sent` — tasks this peer delegated (`from`)
699
+ * - `received` — tasks addressed to this peer (or broadcast)
700
+ * - `all` (default) — either side
701
+ * Optional `status` filter matches the ledger's current status.
702
+ */
703
+ listTasks(peerId, options) {
704
+ const role = options?.role ?? "all";
705
+ const limit = Math.max(1, options?.limit ?? 50);
706
+ const out = [];
707
+ for (const record of this.taskLedger.values()) {
708
+ const isSent = record.from === peerId;
709
+ const isReceived = record.to === peerId || record.to === BROADCAST;
710
+ if (role === "sent" && !isSent) continue;
711
+ if (role === "received" && !isReceived) continue;
712
+ if (role === "all" && !isSent && !isReceived) continue;
713
+ if (options?.status !== void 0 && record.status !== options.status) continue;
714
+ out.push({ ...record, acks: record.acks.map((a) => ({ ...a })) });
715
+ }
716
+ out.sort((a, b) => b.updatedAt - a.updatedAt);
717
+ return out.slice(0, limit);
637
718
  }
638
719
  /** Recent messages involving `peer` (inbound and outbound, plus group
639
720
  * channels the peer belongs to), newest first. */
@@ -856,11 +937,29 @@ var AgentHub = class {
856
937
  * Long-poll for the next message addressed to `peer`: resolves immediately
857
938
  * when a matching one is queued, otherwise waits up to `timeoutMs` (capped
858
939
  * by `waitTimeoutMs`) or until `signal` aborts. `from` narrows to one sender.
940
+ * `ref` narrows to the ack of a specific task message id.
941
+ *
942
+ * Takes ONLY the first matching queued message. Calling poll() here would
943
+ * drain the whole mailbox and discard every message after the first —
944
+ * a multi-message burst (or a reconnect with a full queue) would silently
945
+ * lose messages that never reach wait/poll again.
859
946
  */
860
- wait(peerId, timeoutMs, from, signal) {
947
+ wait(peerId, timeoutMs, from, signal, ref) {
861
948
  const startedAt = Date.now();
862
- const queued = this.poll(peerId, from)[0];
863
- if (queued) return Promise.resolve({ type: "message", message: queued });
949
+ const matches = (message) => {
950
+ if (from !== void 0 && message.from !== from) return false;
951
+ if (ref !== void 0 && !(message.kind === "ack" && message.ref === ref)) return false;
952
+ return true;
953
+ };
954
+ const queue = this.queues.get(peerId);
955
+ if (queue !== void 0 && queue.length > 0) {
956
+ const index = queue.findIndex(matches);
957
+ if (index >= 0) {
958
+ const [queued] = queue.splice(index, 1);
959
+ this.options.onMailboxesChanged?.();
960
+ return Promise.resolve({ type: "message", message: queued });
961
+ }
962
+ }
864
963
  const budget = Math.max(1, Math.min(Math.floor(timeoutMs), this.options.waitTimeoutMs));
865
964
  return new Promise((resolve) => {
866
965
  let settled = false;
@@ -889,7 +988,13 @@ var AgentHub = class {
889
988
  }
890
989
  signal?.addEventListener("abort", onAbort, { once: true });
891
990
  const list = this.waiters.get(peerId) ?? [];
892
- list.push({ resolve: settle, timer, onAbort, ...from !== void 0 ? { from } : {} });
991
+ list.push({
992
+ resolve: settle,
993
+ timer,
994
+ onAbort,
995
+ ...from !== void 0 ? { from } : {},
996
+ ...ref !== void 0 ? { ref } : {}
997
+ });
893
998
  this.waiters.set(peerId, list);
894
999
  });
895
1000
  }
@@ -919,7 +1024,11 @@ var AgentHub = class {
919
1024
  /** Queue or hand off a message; wake the first matching waiter for its target. */
920
1025
  deliver(target, message) {
921
1026
  const list = this.waiters.get(target) ?? [];
922
- const index = list.findIndex((waiter) => waiter.from === void 0 || waiter.from === message.from);
1027
+ const index = list.findIndex((waiter) => {
1028
+ if (waiter.from !== void 0 && waiter.from !== message.from) return false;
1029
+ if (waiter.ref !== void 0 && !(message.kind === "ack" && message.ref === waiter.ref)) return false;
1030
+ return true;
1031
+ });
923
1032
  if (index >= 0) {
924
1033
  const [waiter] = list.splice(index, 1);
925
1034
  this.waiters.set(target, list);
@@ -1362,7 +1471,7 @@ function hubTools(hub, registry, options) {
1362
1471
  },
1363
1472
  {
1364
1473
  name: "bridge_task",
1365
- description: "Delegate a structured task to another agent. The receiving agent decides whether to accept; expect an ack (accepted/rejected/done/failed) via bridge_wait / bridge_poll.",
1474
+ description: "Delegate a structured task to another agent. The receiving agent decides whether to accept; expect an ack (accepted/rejected/done/failed) via bridge_wait / bridge_poll. Track progress with bridge_task_status(ref) or bridge_tasks; wait specifically for the ack with bridge_wait({ ref }).",
1366
1475
  inputSchema: schema(
1367
1476
  {
1368
1477
  to: str('Target peerId, or "all" to broadcast.'),
@@ -1380,7 +1489,7 @@ function hubTools(hub, registry, options) {
1380
1489
  },
1381
1490
  {
1382
1491
  name: "bridge_ack",
1383
- description: "Acknowledge a message received from another agent (usually a delegated task): accepted | rejected | done | failed. The ack is routed back to the original sender of `ref`.",
1492
+ description: "Acknowledge a message received from another agent (usually a delegated task): accepted | rejected | done | failed. The ack is routed back to the original sender of `ref` and updates the hub task ledger (see bridge_task_status / bridge_tasks).",
1384
1493
  inputSchema: schema(
1385
1494
  {
1386
1495
  ref: str("The id of the message being acknowledged."),
@@ -1398,14 +1507,61 @@ function hubTools(hub, registry, options) {
1398
1507
  return receipt(hub.sendAck(peer, String(args.ref), { status, ...args.note !== void 0 ? { note: String(args.note) } : {} }));
1399
1508
  })
1400
1509
  },
1510
+ {
1511
+ name: "bridge_tasks",
1512
+ description: "List delegated tasks involving you, newest first. role=sent = tasks you assigned; role=received = tasks assigned to you (or broadcast); status filters the ledger state (pending/accepted/rejected/done/failed). Each row carries the full ack timeline.",
1513
+ inputSchema: schema({
1514
+ role: { type: "string", enum: ["sent", "received", "all"], description: "Which side of the task to list (default all)." },
1515
+ status: { type: "string", enum: ["pending", "accepted", "rejected", "done", "failed"], description: "Only tasks whose current ledger status matches." },
1516
+ limit: int("How many tasks to return (default 50).")
1517
+ }),
1518
+ handler: wrap(true, async (args, peer) => {
1519
+ const role = args.role === void 0 ? void 0 : String(args.role);
1520
+ if (role !== void 0 && role !== "sent" && role !== "received" && role !== "all") {
1521
+ throw new Error(`invalid role: ${role} (expected sent | received | all)`);
1522
+ }
1523
+ const status = args.status === void 0 ? void 0 : String(args.status);
1524
+ const validStatus = ["pending", "accepted", "rejected", "done", "failed"];
1525
+ if (status !== void 0 && !validStatus.includes(status)) {
1526
+ throw new Error(`invalid status: ${status} (expected ${validStatus.join(" | ")})`);
1527
+ }
1528
+ const tasks = hub.listTasks(peer, {
1529
+ ...role !== void 0 ? { role } : {},
1530
+ ...status !== void 0 ? { status } : {},
1531
+ ...args.limit !== void 0 ? { limit: Number(args.limit) } : {}
1532
+ });
1533
+ return { tasks };
1534
+ })
1535
+ },
1536
+ {
1537
+ name: "bridge_task_status",
1538
+ description: "Status of one delegated task: current ledger state (pending \u2192 accepted \u2192 done/failed, or rejected), the original prompt/context/deliverable, and every ack event (who, status, note, when). Use the task message id (the same id you pass as bridge_ack ref).",
1539
+ inputSchema: schema({ ref: str("Task message id (the id returned by bridge_task, also used as ack ref).") }, ["ref"]),
1540
+ handler: wrap(true, async (args, peer, sessionId) => {
1541
+ const ref = String(args.ref);
1542
+ const task = hub.taskOf(ref);
1543
+ if (task === void 0) throw new Error(`unknown task: ${ref}`);
1544
+ if (task.from !== peer && task.to !== peer && task.to !== BROADCAST) {
1545
+ requireManager(peer, `reading task '${ref}'`, sessionId);
1546
+ }
1547
+ return { task };
1548
+ })
1549
+ },
1401
1550
  {
1402
1551
  name: "bridge_wait",
1403
- description: "Wait (long-poll) for the next message addressed to you. Resolves immediately when one is queued; otherwise blocks until one arrives or the timeout fires. `from` narrows to one sender. Loop this tool to hold a real-time conversation.",
1552
+ description: "Wait (long-poll) for the next message addressed to you. Resolves immediately when one is queued; otherwise blocks until one arrives or the timeout fires. `from` narrows to one sender. `ref` waits specifically for the ack of that task id. Loop this tool to hold a real-time conversation.",
1404
1553
  inputSchema: schema({
1405
1554
  from: optStr("Only wait for messages from this peerId."),
1555
+ ref: optStr("Only wait for the ack of this task message id (bridge_task id)."),
1406
1556
  timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`)
1407
1557
  }),
1408
- handler: wrap(true, async (args, peer) => presentWait(await hub.wait(peer, args.timeoutMs === void 0 ? options.defaultWaitMs ?? DEFAULT_WAIT_MS : Number(args.timeoutMs), args.from === void 0 ? void 0 : String(args.from))))
1558
+ handler: wrap(true, async (args, peer) => presentWait(await hub.wait(
1559
+ peer,
1560
+ args.timeoutMs === void 0 ? options.defaultWaitMs ?? DEFAULT_WAIT_MS : Number(args.timeoutMs),
1561
+ args.from === void 0 ? void 0 : String(args.from),
1562
+ void 0,
1563
+ args.ref === void 0 ? void 0 : String(args.ref)
1564
+ )))
1409
1565
  },
1410
1566
  {
1411
1567
  name: "bridge_poll",
@@ -1428,7 +1584,11 @@ function hubTools(hub, registry, options) {
1428
1584
  {
1429
1585
  name: "bridge_history",
1430
1586
  description: 'Recent messages involving you (newest first). Use to refresh context after a reconnect. Reading ANOTHER peer\'s conversation \u2014 or `peer: "all"` for the unfiltered tail \u2014 requires manager rights (hub managerPeers).',
1431
- inputSchema: schema({ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'), limit: int("How many messages to return (default 20).") }),
1587
+ inputSchema: schema({
1588
+ peer: optStr('PeerId whose conversation to inspect; "all" = every peer; default: yourself. Other peers / "all" require manager rights.'),
1589
+ channel: optStr("Read a group channel history instead of a peer conversation (you must be a member, or a manager)."),
1590
+ limit: int("How many messages to return (default 20).")
1591
+ }),
1432
1592
  handler: wrap(true, async (args, peer, sessionId) => {
1433
1593
  const limit = Math.min(args.limit === void 0 ? 20 : Number(args.limit), 1e3);
1434
1594
  const target = args.peer === void 0 ? peer : String(args.peer);
@@ -1736,6 +1896,7 @@ function adminPage() {
1736
1896
  var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
1737
1897
  var LATEST_VERSION = SUPPORTED_VERSIONS[0];
1738
1898
  var MAX_BODY_BYTES = 1048576;
1899
+ var HEARTBEAT_MS = 2e4;
1739
1900
  var SessionRegistry = class {
1740
1901
  sessions = /* @__PURE__ */ new Set();
1741
1902
  /** sessionId → peerId claimed via `bridge_register`. */
@@ -1881,7 +2042,7 @@ var SessionRegistry = class {
1881
2042
  var McpStreamableHttpServer = class {
1882
2043
  constructor(tools, info, registry, log = () => {
1883
2044
  }, onInitialize = () => {
1884
- }, authenticate, adminApi) {
2045
+ }, authenticate, adminApi, heartbeatMs) {
1885
2046
  this.tools = tools;
1886
2047
  this.info = info;
1887
2048
  this.registry = registry;
@@ -1889,10 +2050,21 @@ var McpStreamableHttpServer = class {
1889
2050
  this.onInitialize = onInitialize;
1890
2051
  this.authenticate = authenticate;
1891
2052
  this.adminApi = adminApi;
2053
+ this.heartbeatMs = heartbeatMs;
1892
2054
  }
1893
2055
  sseStreams = /* @__PURE__ */ new Map();
2056
+ heartbeatTimer;
1894
2057
  /** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
1895
2058
  attach(server, path2) {
2059
+ this.heartbeatTimer = setInterval(() => {
2060
+ if (this.sseStreams.size === 0) return;
2061
+ this.notifyAll("notifications/message", {
2062
+ level: "debug",
2063
+ logger: "bridge",
2064
+ data: { event: "heartbeat", ts: Date.now() }
2065
+ });
2066
+ }, this.heartbeatMs ?? HEARTBEAT_MS);
2067
+ this.heartbeatTimer.unref?.();
1896
2068
  server.on("request", (req, res) => {
1897
2069
  const url = new URL(req.url ?? "/", "http://localhost");
1898
2070
  if (url.pathname === ADMIN_PATH && req.method === "GET") {
@@ -1900,6 +2072,11 @@ var McpStreamableHttpServer = class {
1900
2072
  res.end(adminPage());
1901
2073
  return;
1902
2074
  }
2075
+ if (url.pathname === "/healthz") {
2076
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", ...corsHeaders() });
2077
+ res.end(JSON.stringify({ ok: true, ts: Date.now() }));
2078
+ return;
2079
+ }
1903
2080
  if (url.pathname.startsWith(ADMIN_API_PATH) && this.adminApi !== void 0) {
1904
2081
  void this.handleAdminApi(req, res, url);
1905
2082
  return;
@@ -1925,6 +2102,7 @@ var McpStreamableHttpServer = class {
1925
2102
  }
1926
2103
  /** Close all open SSE streams (called on server shutdown). */
1927
2104
  close() {
2105
+ if (this.heartbeatTimer !== void 0) clearInterval(this.heartbeatTimer);
1928
2106
  for (const stream of this.sseStreams.values()) stream.end();
1929
2107
  this.sseStreams.clear();
1930
2108
  }
@@ -2245,7 +2423,7 @@ var SQLiteStateStore = class {
2245
2423
  id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
2246
2424
  );
2247
2425
  `);
2248
- log.warn(`sqlite state open: ${file}`);
2426
+ log.info(`sqlite state open: ${file}`);
2249
2427
  }
2250
2428
  db;
2251
2429
  buffer = [];
@@ -2340,7 +2518,7 @@ var SQLiteStateStore = class {
2340
2518
 
2341
2519
  // src/index.ts
2342
2520
  var SERVER_NAME = "agent-comm-hub";
2343
- var SERVER_VERSION = "0.7.0";
2521
+ var SERVER_VERSION = "0.8.0";
2344
2522
  var DEFAULT_HOST = "127.0.0.1";
2345
2523
  var DEFAULT_PORT = 18764;
2346
2524
  var DEFAULT_PATH = "/mcp";
@@ -2358,9 +2536,10 @@ var DEFAULT_CONFIG = {
2358
2536
  connectedWindowMs: 3e4,
2359
2537
  peerIdleTimeoutMs: 6e5,
2360
2538
  // The companion desktop app (and `agent-comm-hub status` probe) registers
2361
- // as `agent-hub-cli`; it is the natural roster manager. Agents themselves
2362
- // are NOT managers — they keep chatting, a GUI manages.
2363
- managerPeers: ["agent-hub-cli"]
2539
+ // as `agent-hub-cli`; the web admin console registers as `hub-admin`.
2540
+ // Both are roster managers. Agents themselves are NOT managers — they
2541
+ // keep chatting, a GUI manages.
2542
+ managerPeers: ["agent-hub-cli", "hub-admin"]
2364
2543
  };
2365
2544
  function authTokensAdmin(file, reloadAuth) {
2366
2545
  if (file === void 0) return void 0;
@@ -2610,7 +2789,8 @@ function startHub(config = {}, log = console) {
2610
2789
  }
2611
2790
  },
2612
2791
  auth,
2613
- authTokensAdmin(resolved.authTokens, authReload)
2792
+ authTokensAdmin(resolved.authTokens, authReload),
2793
+ resolved.heartbeatMs
2614
2794
  );
2615
2795
  const server = createServer();
2616
2796
  mcp.attach(server, resolved.path);
@@ -2660,6 +2840,7 @@ export {
2660
2840
  SERVER_VERSION,
2661
2841
  SQLiteStateStore,
2662
2842
  SessionRegistry,
2843
+ TERMINAL_TASK_STATUSES,
2663
2844
  addToken,
2664
2845
  autoRegisterPeer,
2665
2846
  decodeContent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-comm-hub",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Generic multi-peer MCP hub: any MCP-capable agent (MiniMax Code, Claude Code, opencode, Codex, Gemini CLI, DSH, ...) connects to one local streamable-http endpoint and they chat, delegate tasks, and acknowledge in real time",
5
5
  "keywords": [
6
6
  "mcp",
@@ -38,7 +38,7 @@
38
38
  "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=lib/cli.js && esbuild src/index.ts --bundle --platform=node --format=esm --outfile=lib/index.js && esbuild src/setup.ts --bundle --platform=node --format=esm --outfile=lib/setup.js",
39
39
  "build:test": "esbuild test/entry.ts --bundle --platform=node --format=esm --outfile=test/entry.mjs && esbuild test/setup-entry.ts --bundle --platform=node --format=esm --outfile=test/setup-entry.mjs && esbuild test/ops-entry.ts --bundle --platform=node --format=esm --outfile=test/ops-entry.mjs && esbuild test/herdr-entry.ts --bundle --platform=node --format=esm --outfile=test/herdr-entry.mjs && esbuild test/discover-entry.ts --bundle --platform=node --format=esm --outfile=test/discover-entry.mjs",
40
40
  "typecheck": "tsc --noEmit",
41
- "test": "pnpm run build:test && node test/smoke.mjs && node test/setup.mjs && node test/ops.mjs && node test/herdr.mjs && node test/discover.mjs",
41
+ "test": "pnpm run build:test && pnpm run build && node test/smoke.mjs && node test/setup.mjs && node test/ops.mjs && node test/herdr.mjs && node test/discover.mjs && node test/e2e.mjs",
42
42
  "pack": "pnpm run build && pnpm pack"
43
43
  }
44
44
  }