agent-comm-hub 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -430,7 +430,7 @@ const hub = startHub({ port: 18764 }, console) // returns { hub, registry, serve
430
430
  ```bash
431
431
  pnpm install
432
432
  pnpm typecheck # tsc --noEmit (strict)
433
- pnpm test # test suite (282 checks: 156 smoke + 37 setup + 11 ops + 35 herdr + 23 discover + 20 e2e)
433
+ pnpm test # test suite (291 checks: 165 smoke + 37 setup + 11 ops + 35 herdr + 23 discover + 20 e2e)
434
434
  npm run build:admin # admin/ workspace (npm) → single-file assets/admin.html
435
435
  pnpm run build # esbuild → lib/{cli,index,setup}.js (zero deps)
436
436
  pnpm pack # build + npm pack (publishing artifact)
@@ -481,4 +481,4 @@ Tests cover registration, duplicate rejection, chat routing, sender-filtered wai
481
481
 
482
482
  ## License
483
483
 
484
- MIT — see [LICENSE](LICENSE). Contributions welcome: keep the 282-check suite green (`pnpm test`) and zero runtime dependencies. Architecture: [ARCHITECTURE.md](ARCHITECTURE.md).
484
+ MIT — see [LICENSE](LICENSE). Contributions welcome: keep the 291-check suite green (`pnpm test`) and zero runtime dependencies. Architecture: [ARCHITECTURE.md](ARCHITECTURE.md).
package/README.zh.md CHANGED
@@ -276,7 +276,7 @@ const hub = startHub({ port: 18764 }, console) // 返回 { hub, registry, server
276
276
  ```bash
277
277
  pnpm install
278
278
  pnpm typecheck # tsc --noEmit(strict)
279
- pnpm test # 测试套件(282 项:156 冒烟 + 37 安装器 + 11 运维 + 35 herdr + 23 发现 + 20 e2e)
279
+ pnpm test # 测试套件(291 项:165 冒烟 + 37 安装器 + 11 运维 + 35 herdr + 23 发现 + 20 e2e)
280
280
  npm run build:admin # admin/ 工作区(用 npm)→ 单文件 assets/admin.html
281
281
  pnpm run build # esbuild → lib/{cli,index,setup}.js(零依赖)
282
282
  pnpm pack # 构建 + npm pack(发布产物)
package/agents/SKILL.md CHANGED
@@ -25,7 +25,7 @@ Optional: call `bridge_register(peerId)` to claim a readable id
25
25
 
26
26
  - `bridge_chat(to, message, threadId?, ref?)` — send a chat to a peer, group
27
27
  id, or `"all"` (broadcast). `threadId` groups a multi-turn dialog; `ref`
28
- marks a reply-to.
28
+ marks a reply-to and is capped at 5 hops per chain (see Notes).
29
29
  - `bridge_task(to, prompt, context?, deliverable?, timeoutMs?, threadId?)` —
30
30
  delegate a structured task. `timeoutMs` is a soft SLA: the hub marks the
31
31
  task `timeout` if no terminal ack arrives in time.
@@ -104,4 +104,12 @@ Follow the scenario directly (tool names stay as-is):
104
104
  - Cadence: use 10–30 s short polls when the peer is active; check
105
105
  `bridge_peers()` when unsure.
106
106
  - Task terminal states (`done`/`failed`/`rejected`/`timeout`) cannot be
107
- reopened; start a new task instead of re-acking.
107
+ reopened; start a new task instead of re-acking.
108
+ - **Reply chains are capped at 5 hops.** Every message carrying `ref` sits one
109
+ hop deeper than the message it points at, and the hub rejects the 6th with
110
+ `hop limit exceeded`. You only run into it when you keep replying
111
+ (`ref`) or re-delegating along one chain instead of starting a new message —
112
+ two agents answering each other's replies forever is exactly what the cap
113
+ exists to stop. When it happens, stop extending that chain: send a fresh
114
+ message with no `ref` (a new `threadId` keeps it readable as one dialog) and
115
+ restate the context in it, or continue with `bridge_task` for new work.
package/lib/cli.js CHANGED
@@ -471,6 +471,7 @@ function decodeContent(kind, content) {
471
471
  // src/hub.ts
472
472
  var MAX_PROFILES = 512;
473
473
  var MAX_TASKS = 1024;
474
+ var MAX_TIMER_DELAY_MS = 2147483647;
474
475
  var AgentHub = class {
475
476
  constructor(options) {
476
477
  this.options = options;
@@ -670,8 +671,9 @@ var AgentHub = class {
670
671
  * wait for acks. */
671
672
  sendTask(from, to, task) {
672
673
  const threadId = task.threadId;
673
- const message = this.route(from, to, "task", JSON.stringify(task), void 0, threadId);
674
+ const message = this.compose(from, to, "task", JSON.stringify(task), void 0, threadId);
674
675
  this.recordTask(message, task);
676
+ this.dispatch(message);
675
677
  return message;
676
678
  }
677
679
  /** Send an acknowledgement back to the sender of `ref`. Updates the task
@@ -724,11 +726,17 @@ var AgentHub = class {
724
726
  this.evictOverflowTasks();
725
727
  this.options.onTaskChanged?.(this.taskOf(record.id));
726
728
  }
727
- /** Arm the SLA timer for a task that supplied `timeoutMs`. */
729
+ /** Arm the SLA timer for a task that supplied `timeoutMs`. A deadline
730
+ * beyond {@link MAX_TIMER_DELAY_MS} cannot be expressed as one delay — the
731
+ * timer would overflow and fire after ~1 ms — so it is re-armed in segments
732
+ * of that size. `deadlineAt` stays the authoritative deadline and each
733
+ * segment recomputes the remaining time, so a long SLA is honored exactly
734
+ * (no drift); settling or evicting the task clears the pending segment, and
735
+ * clearing the timer is what ends the chain. */
728
736
  scheduleTaskTimeout(record) {
729
737
  if (record.deadlineAt === void 0) return;
730
- const delay = Math.max(0, record.deadlineAt - Date.now());
731
- const timer = setTimeout(() => this.fireTaskTimeout(record.id), delay);
738
+ const remaining = record.deadlineAt - Date.now();
739
+ const timer = remaining > MAX_TIMER_DELAY_MS ? setTimeout(() => this.scheduleTaskTimeout(record), MAX_TIMER_DELAY_MS) : setTimeout(() => this.fireTaskTimeout(record.id), Math.max(0, remaining));
732
740
  timer.unref?.();
733
741
  this.taskTimers.set(record.id, timer);
734
742
  }
@@ -1058,6 +1066,7 @@ var AgentHub = class {
1058
1066
  const m = raw;
1059
1067
  if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
1060
1068
  if (KINDS.includes(m.kind) === false) continue;
1069
+ const hop = importHop(m.hop);
1061
1070
  valid.push({
1062
1071
  id: m.id,
1063
1072
  from: m.from,
@@ -1067,6 +1076,7 @@ var AgentHub = class {
1067
1076
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
1068
1077
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1069
1078
  ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
1079
+ ...hop !== void 0 ? { hop } : {},
1070
1080
  ts: m.ts
1071
1081
  });
1072
1082
  }
@@ -1092,6 +1102,7 @@ var AgentHub = class {
1092
1102
  if (typeof raw !== "object" || raw === null) continue;
1093
1103
  const m = raw;
1094
1104
  if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
1105
+ const hop = importHop(m.hop);
1095
1106
  restored.push({
1096
1107
  id: m.id,
1097
1108
  from: m.from,
@@ -1101,6 +1112,7 @@ var AgentHub = class {
1101
1112
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
1102
1113
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1103
1114
  ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
1115
+ ...hop !== void 0 ? { hop } : {},
1104
1116
  ts: m.ts
1105
1117
  });
1106
1118
  }
@@ -1211,6 +1223,15 @@ var AgentHub = class {
1211
1223
  /** Create a message from `from` addressed to `to` and deliver it. `to` may
1212
1224
  * be a peer id, {@link BROADCAST}, or a group id. */
1213
1225
  route(from, to, kind, content, ref, threadId) {
1226
+ const message = this.compose(from, to, kind, content, ref, threadId);
1227
+ this.dispatch(message);
1228
+ return message;
1229
+ }
1230
+ /** Validate, build, and record a message from `from` to `to` (`to` may be a
1231
+ * peer id, {@link BROADCAST}, or a group id). Composing and dispatching are
1232
+ * separate steps because a caller may need ledger state to exist before
1233
+ * anything reaches a recipient — {@link sendTask} books delivery times. */
1234
+ compose(from, to, kind, content, ref, threadId) {
1214
1235
  if (!this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
1215
1236
  const group = this.groups.get(to);
1216
1237
  if (to !== BROADCAST && group === void 0 && !this.lastSeen.has(to)) {
@@ -1238,18 +1259,25 @@ var AgentHub = class {
1238
1259
  };
1239
1260
  this.lastSeen.set(from, message.ts);
1240
1261
  this.remember(message);
1241
- if (to === BROADCAST) {
1262
+ return message;
1263
+ }
1264
+ /** Fan a composed message out to its recipients: broadcast, group members,
1265
+ * or the single addressed peer. */
1266
+ dispatch(message) {
1267
+ if (message.to === BROADCAST) {
1242
1268
  for (const peer of this.peers()) {
1243
- if (peer !== from) this.deliver(peer, message);
1269
+ if (peer !== message.from) this.deliver(peer, message);
1244
1270
  }
1245
- } else if (group !== void 0) {
1271
+ return;
1272
+ }
1273
+ const group = this.groups.get(message.to);
1274
+ if (group !== void 0) {
1246
1275
  for (const member of group.members) {
1247
- if (member !== from) this.deliver(member, message);
1276
+ if (member !== message.from) this.deliver(member, message);
1248
1277
  }
1249
- } else {
1250
- this.deliver(to, message);
1278
+ return;
1251
1279
  }
1252
- return message;
1280
+ this.deliver(message.to, message);
1253
1281
  }
1254
1282
  /** Queue or hand off a message; wake the first matching waiter for its target.
1255
1283
  * History / onMessage are recorded by the caller (route / sendToGroup) —
@@ -1284,6 +1312,9 @@ var AgentHub = class {
1284
1312
  this.options.onMessage?.(message);
1285
1313
  }
1286
1314
  };
1315
+ function importHop(value) {
1316
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1317
+ }
1287
1318
  function drainFrom(queue, from) {
1288
1319
  const kept = [];
1289
1320
  const drained = [];
@@ -1711,14 +1742,14 @@ function hubTools(hub, registry, options) {
1711
1742
  },
1712
1743
  {
1713
1744
  name: "bridge_task",
1714
- description: 'Delegate a structured task to another agent (peer, group, or "all"). The assignee acks with accepted | working | rejected | done | failed (bridge_ack). Track with bridge_task_status(ref) / bridge_tasks; wait for the ack with bridge_wait({ ref }). Optional timeoutMs marks the task `timeout` when the SLA expires without a terminal ack.',
1745
+ description: 'Delegate a structured task to another agent (peer, group, or "all"). The assignee acks with accepted | working | rejected | done | failed (bridge_ack). Track with bridge_task_status(ref) / bridge_tasks; wait for the ack with bridge_wait({ ref }). Optional timeoutMs marks the task `timeout` when the SLA expires without a terminal ack; any positive duration is honored, including multi-day deadlines (the hub re-arms its timer instead of overflowing it).',
1715
1746
  inputSchema: schema(
1716
1747
  {
1717
1748
  to: str('Target peerId, group id, or "all" to broadcast.'),
1718
1749
  prompt: str("What the receiving agent should do."),
1719
1750
  context: optStr("Optional background information for the task."),
1720
1751
  deliverable: optStr("Optional expected deliverable description."),
1721
- timeoutMs: int("Optional soft SLA in ms; hub marks the task timeout when it expires."),
1752
+ timeoutMs: int("Optional soft SLA in ms from now (e.g. 30000). No upper bound \u2014 a deadline past ~24.8 days is honored as well."),
1722
1753
  threadId: optStr("Optional conversation / workflow thread id.")
1723
1754
  },
1724
1755
  ["to", "prompt"]
@@ -1790,13 +1821,15 @@ function hubTools(hub, registry, options) {
1790
1821
  },
1791
1822
  {
1792
1823
  name: "bridge_task_status",
1793
- description: "Status of one delegated task: current ledger state (pending \u2192 accepted/working \u2192 done/failed/timeout, or rejected), the original prompt/context/deliverable, per-assignee state, and every ack event (who, status, note, progress, when). Use the task message id (the same id you pass as bridge_ack ref).",
1824
+ description: "Status of one delegated task: current ledger state (pending \u2192 accepted/working \u2192 done/failed/timeout, or rejected), the original prompt/context/deliverable, per-assignee state, and every ack event (who, status, note, progress, when). Use the task message id (the same id you pass as bridge_ack ref). Readable by the sender, by an assignee (a group task carries the GROUP id in `to`, so its assignees read it through their assignee row) and by a group member; everyone else needs manager rights.",
1794
1825
  inputSchema: schema({ ref: str("Task message id (the id returned by bridge_task, also used as ack ref).") }, ["ref"]),
1795
1826
  handler: wrap(true, async (args, peer, sessionId) => {
1796
1827
  const ref = String(args.ref);
1797
1828
  const task = hub.taskOf(ref);
1798
1829
  if (task === void 0) throw new Error(`unknown task: ${ref}`);
1799
- if (task.from !== peer && task.to !== peer && task.to !== BROADCAST) {
1830
+ const isAssignee = task.assignees.some((assignee) => assignee.peerId === peer);
1831
+ const isGroupMember = hub.groupOf(task.to)?.members.includes(peer) ?? false;
1832
+ if (task.from !== peer && task.to !== peer && task.to !== BROADCAST && !isAssignee && !isGroupMember) {
1800
1833
  requireManager(peer, `reading task '${ref}'`, sessionId);
1801
1834
  }
1802
1835
  return { task };
@@ -2637,7 +2670,7 @@ function readBody(req) {
2637
2670
  import { DatabaseSync } from "node:sqlite";
2638
2671
  var KIND_NAMES = ["chat", "task", "notice", "ack"];
2639
2672
  function messageToRow(message) {
2640
- return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts];
2673
+ return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts, message.hop ?? null];
2641
2674
  }
2642
2675
  function rowToMessage(row) {
2643
2676
  const id = row.id;
@@ -2657,6 +2690,9 @@ function rowToMessage(row) {
2657
2690
  ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2658
2691
  ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2659
2692
  ...typeof row.thread_id === "string" && row.thread_id !== "" ? { threadId: row.thread_id } : {},
2693
+ // Chain depth: rows written before the hop column restore without it
2694
+ // (treated as a chain origin by importHistory — never as a bogus depth).
2695
+ ...typeof row.hop === "number" && Number.isFinite(row.hop) && row.hop > 0 ? { hop: row.hop } : {},
2660
2696
  ts
2661
2697
  };
2662
2698
  }
@@ -2671,11 +2707,11 @@ var SQLiteStateStore = class {
2671
2707
  );
2672
2708
  CREATE TABLE IF NOT EXISTS messages (
2673
2709
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2674
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
2710
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL, hop INTEGER
2675
2711
  );
2676
2712
  CREATE TABLE IF NOT EXISTS mailboxes (
2677
2713
  peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2678
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
2714
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL, hop INTEGER
2679
2715
  );
2680
2716
  CREATE TABLE IF NOT EXISTS groups (
2681
2717
  id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
@@ -2685,9 +2721,11 @@ var SQLiteStateStore = class {
2685
2721
  );
2686
2722
  `);
2687
2723
  for (const table of ["messages", "mailboxes"]) {
2688
- try {
2689
- this.db.exec(`ALTER TABLE ${table} ADD COLUMN thread_id TEXT`);
2690
- } catch {
2724
+ for (const column of ["thread_id TEXT", "hop INTEGER"]) {
2725
+ try {
2726
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column}`);
2727
+ } catch {
2728
+ }
2691
2729
  }
2692
2730
  }
2693
2731
  log2.info(`sqlite state open: ${file}`);
@@ -2701,14 +2739,14 @@ var SQLiteStateStore = class {
2701
2739
  /** Insert buffered messages and re-snapshot mailboxes/profiles/groups/tasks. */
2702
2740
  flush(snapshot) {
2703
2741
  const insert = this.db.prepare(
2704
- "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2742
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts, hop) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2705
2743
  );
2706
2744
  for (const message of this.buffer.splice(0, this.buffer.length)) {
2707
2745
  insert.run(...messageToRow(message));
2708
2746
  }
2709
2747
  this.db.exec("DELETE FROM mailboxes");
2710
2748
  const insertMail = this.db.prepare(
2711
- "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2749
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts, hop) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2712
2750
  );
2713
2751
  for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2714
2752
  for (const message of queue) insertMail.run(peer, ...messageToRow(message));
@@ -2801,7 +2839,7 @@ var SQLiteStateStore = class {
2801
2839
 
2802
2840
  // src/index.ts
2803
2841
  var SERVER_NAME = "agent-comm-hub";
2804
- var SERVER_VERSION = "0.9.0";
2842
+ var SERVER_VERSION = "0.9.1";
2805
2843
  var DEFAULT_HOST = "127.0.0.1";
2806
2844
  var DEFAULT_PORT = 18764;
2807
2845
  var DEFAULT_PATH = "/mcp";
@@ -3859,7 +3897,8 @@ Hub options:
3859
3897
  --herdr-timeout-ms <n> Default cap for one herdr call in ms (default 30000)
3860
3898
  --manager-peers <ids> Comma-separated peer ids allowed to manage the
3861
3899
  roster (rename others / kick), or "all".
3862
- (default agent-hub-cli \u2014 the desktop GUI identity)
3900
+ (default agent-hub-cli,hub-admin \u2014 the desktop
3901
+ GUI and the web admin console identities)
3863
3902
  --state-file <path> Persist the peer roster (aliases, client info) to
3864
3903
  this JSON file so identities survive restarts
3865
3904
  (default ~/.agent-comm-hub/roster.json; "off"
package/lib/index.js CHANGED
@@ -472,6 +472,7 @@ function decodeContent(kind, content) {
472
472
  // src/hub.ts
473
473
  var MAX_PROFILES = 512;
474
474
  var MAX_TASKS = 1024;
475
+ var MAX_TIMER_DELAY_MS = 2147483647;
475
476
  var AgentHub = class {
476
477
  constructor(options) {
477
478
  this.options = options;
@@ -671,8 +672,9 @@ var AgentHub = class {
671
672
  * wait for acks. */
672
673
  sendTask(from, to, task) {
673
674
  const threadId = task.threadId;
674
- const message = this.route(from, to, "task", JSON.stringify(task), void 0, threadId);
675
+ const message = this.compose(from, to, "task", JSON.stringify(task), void 0, threadId);
675
676
  this.recordTask(message, task);
677
+ this.dispatch(message);
676
678
  return message;
677
679
  }
678
680
  /** Send an acknowledgement back to the sender of `ref`. Updates the task
@@ -725,11 +727,17 @@ var AgentHub = class {
725
727
  this.evictOverflowTasks();
726
728
  this.options.onTaskChanged?.(this.taskOf(record.id));
727
729
  }
728
- /** Arm the SLA timer for a task that supplied `timeoutMs`. */
730
+ /** Arm the SLA timer for a task that supplied `timeoutMs`. A deadline
731
+ * beyond {@link MAX_TIMER_DELAY_MS} cannot be expressed as one delay — the
732
+ * timer would overflow and fire after ~1 ms — so it is re-armed in segments
733
+ * of that size. `deadlineAt` stays the authoritative deadline and each
734
+ * segment recomputes the remaining time, so a long SLA is honored exactly
735
+ * (no drift); settling or evicting the task clears the pending segment, and
736
+ * clearing the timer is what ends the chain. */
729
737
  scheduleTaskTimeout(record) {
730
738
  if (record.deadlineAt === void 0) return;
731
- const delay = Math.max(0, record.deadlineAt - Date.now());
732
- const timer = setTimeout(() => this.fireTaskTimeout(record.id), delay);
739
+ const remaining = record.deadlineAt - Date.now();
740
+ const timer = remaining > MAX_TIMER_DELAY_MS ? setTimeout(() => this.scheduleTaskTimeout(record), MAX_TIMER_DELAY_MS) : setTimeout(() => this.fireTaskTimeout(record.id), Math.max(0, remaining));
733
741
  timer.unref?.();
734
742
  this.taskTimers.set(record.id, timer);
735
743
  }
@@ -1059,6 +1067,7 @@ var AgentHub = class {
1059
1067
  const m = raw;
1060
1068
  if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.to !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
1061
1069
  if (KINDS.includes(m.kind) === false) continue;
1070
+ const hop = importHop(m.hop);
1062
1071
  valid.push({
1063
1072
  id: m.id,
1064
1073
  from: m.from,
@@ -1068,6 +1077,7 @@ var AgentHub = class {
1068
1077
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
1069
1078
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1070
1079
  ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
1080
+ ...hop !== void 0 ? { hop } : {},
1071
1081
  ts: m.ts
1072
1082
  });
1073
1083
  }
@@ -1093,6 +1103,7 @@ var AgentHub = class {
1093
1103
  if (typeof raw !== "object" || raw === null) continue;
1094
1104
  const m = raw;
1095
1105
  if (typeof m.id !== "string" || typeof m.from !== "string" || typeof m.content !== "string" || typeof m.ts !== "number") continue;
1106
+ const hop = importHop(m.hop);
1096
1107
  restored.push({
1097
1108
  id: m.id,
1098
1109
  from: m.from,
@@ -1102,6 +1113,7 @@ var AgentHub = class {
1102
1113
  ...typeof m.ref === "string" ? { ref: m.ref } : {},
1103
1114
  ...typeof m.channel === "string" ? { channel: m.channel } : {},
1104
1115
  ...typeof m.threadId === "string" ? { threadId: m.threadId } : {},
1116
+ ...hop !== void 0 ? { hop } : {},
1105
1117
  ts: m.ts
1106
1118
  });
1107
1119
  }
@@ -1212,6 +1224,15 @@ var AgentHub = class {
1212
1224
  /** Create a message from `from` addressed to `to` and deliver it. `to` may
1213
1225
  * be a peer id, {@link BROADCAST}, or a group id. */
1214
1226
  route(from, to, kind, content, ref, threadId) {
1227
+ const message = this.compose(from, to, kind, content, ref, threadId);
1228
+ this.dispatch(message);
1229
+ return message;
1230
+ }
1231
+ /** Validate, build, and record a message from `from` to `to` (`to` may be a
1232
+ * peer id, {@link BROADCAST}, or a group id). Composing and dispatching are
1233
+ * separate steps because a caller may need ledger state to exist before
1234
+ * anything reaches a recipient — {@link sendTask} books delivery times. */
1235
+ compose(from, to, kind, content, ref, threadId) {
1215
1236
  if (!this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
1216
1237
  const group = this.groups.get(to);
1217
1238
  if (to !== BROADCAST && group === void 0 && !this.lastSeen.has(to)) {
@@ -1239,18 +1260,25 @@ var AgentHub = class {
1239
1260
  };
1240
1261
  this.lastSeen.set(from, message.ts);
1241
1262
  this.remember(message);
1242
- if (to === BROADCAST) {
1263
+ return message;
1264
+ }
1265
+ /** Fan a composed message out to its recipients: broadcast, group members,
1266
+ * or the single addressed peer. */
1267
+ dispatch(message) {
1268
+ if (message.to === BROADCAST) {
1243
1269
  for (const peer of this.peers()) {
1244
- if (peer !== from) this.deliver(peer, message);
1270
+ if (peer !== message.from) this.deliver(peer, message);
1245
1271
  }
1246
- } else if (group !== void 0) {
1272
+ return;
1273
+ }
1274
+ const group = this.groups.get(message.to);
1275
+ if (group !== void 0) {
1247
1276
  for (const member of group.members) {
1248
- if (member !== from) this.deliver(member, message);
1277
+ if (member !== message.from) this.deliver(member, message);
1249
1278
  }
1250
- } else {
1251
- this.deliver(to, message);
1279
+ return;
1252
1280
  }
1253
- return message;
1281
+ this.deliver(message.to, message);
1254
1282
  }
1255
1283
  /** Queue or hand off a message; wake the first matching waiter for its target.
1256
1284
  * History / onMessage are recorded by the caller (route / sendToGroup) —
@@ -1285,6 +1313,9 @@ var AgentHub = class {
1285
1313
  this.options.onMessage?.(message);
1286
1314
  }
1287
1315
  };
1316
+ function importHop(value) {
1317
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1318
+ }
1288
1319
  function drainFrom(queue, from) {
1289
1320
  const kept = [];
1290
1321
  const drained = [];
@@ -1717,14 +1748,14 @@ function hubTools(hub, registry, options) {
1717
1748
  },
1718
1749
  {
1719
1750
  name: "bridge_task",
1720
- description: 'Delegate a structured task to another agent (peer, group, or "all"). The assignee acks with accepted | working | rejected | done | failed (bridge_ack). Track with bridge_task_status(ref) / bridge_tasks; wait for the ack with bridge_wait({ ref }). Optional timeoutMs marks the task `timeout` when the SLA expires without a terminal ack.',
1751
+ description: 'Delegate a structured task to another agent (peer, group, or "all"). The assignee acks with accepted | working | rejected | done | failed (bridge_ack). Track with bridge_task_status(ref) / bridge_tasks; wait for the ack with bridge_wait({ ref }). Optional timeoutMs marks the task `timeout` when the SLA expires without a terminal ack; any positive duration is honored, including multi-day deadlines (the hub re-arms its timer instead of overflowing it).',
1721
1752
  inputSchema: schema(
1722
1753
  {
1723
1754
  to: str('Target peerId, group id, or "all" to broadcast.'),
1724
1755
  prompt: str("What the receiving agent should do."),
1725
1756
  context: optStr("Optional background information for the task."),
1726
1757
  deliverable: optStr("Optional expected deliverable description."),
1727
- timeoutMs: int("Optional soft SLA in ms; hub marks the task timeout when it expires."),
1758
+ timeoutMs: int("Optional soft SLA in ms from now (e.g. 30000). No upper bound \u2014 a deadline past ~24.8 days is honored as well."),
1728
1759
  threadId: optStr("Optional conversation / workflow thread id.")
1729
1760
  },
1730
1761
  ["to", "prompt"]
@@ -1796,13 +1827,15 @@ function hubTools(hub, registry, options) {
1796
1827
  },
1797
1828
  {
1798
1829
  name: "bridge_task_status",
1799
- description: "Status of one delegated task: current ledger state (pending \u2192 accepted/working \u2192 done/failed/timeout, or rejected), the original prompt/context/deliverable, per-assignee state, and every ack event (who, status, note, progress, when). Use the task message id (the same id you pass as bridge_ack ref).",
1830
+ description: "Status of one delegated task: current ledger state (pending \u2192 accepted/working \u2192 done/failed/timeout, or rejected), the original prompt/context/deliverable, per-assignee state, and every ack event (who, status, note, progress, when). Use the task message id (the same id you pass as bridge_ack ref). Readable by the sender, by an assignee (a group task carries the GROUP id in `to`, so its assignees read it through their assignee row) and by a group member; everyone else needs manager rights.",
1800
1831
  inputSchema: schema({ ref: str("Task message id (the id returned by bridge_task, also used as ack ref).") }, ["ref"]),
1801
1832
  handler: wrap(true, async (args, peer, sessionId) => {
1802
1833
  const ref = String(args.ref);
1803
1834
  const task = hub.taskOf(ref);
1804
1835
  if (task === void 0) throw new Error(`unknown task: ${ref}`);
1805
- if (task.from !== peer && task.to !== peer && task.to !== BROADCAST) {
1836
+ const isAssignee = task.assignees.some((assignee) => assignee.peerId === peer);
1837
+ const isGroupMember = hub.groupOf(task.to)?.members.includes(peer) ?? false;
1838
+ if (task.from !== peer && task.to !== peer && task.to !== BROADCAST && !isAssignee && !isGroupMember) {
1806
1839
  requireManager(peer, `reading task '${ref}'`, sessionId);
1807
1840
  }
1808
1841
  return { task };
@@ -2643,7 +2676,7 @@ function readBody(req) {
2643
2676
  import { DatabaseSync } from "node:sqlite";
2644
2677
  var KIND_NAMES = ["chat", "task", "notice", "ack"];
2645
2678
  function messageToRow(message) {
2646
- return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts];
2679
+ return [message.id, message.from, message.to, message.kind, message.content, message.ref ?? null, message.channel ?? null, message.threadId ?? null, message.ts, message.hop ?? null];
2647
2680
  }
2648
2681
  function rowToMessage(row) {
2649
2682
  const id = row.id;
@@ -2663,6 +2696,9 @@ function rowToMessage(row) {
2663
2696
  ...typeof row.ref === "string" && row.ref !== "" ? { ref: row.ref } : {},
2664
2697
  ...typeof row.channel === "string" && row.channel !== "" ? { channel: row.channel } : {},
2665
2698
  ...typeof row.thread_id === "string" && row.thread_id !== "" ? { threadId: row.thread_id } : {},
2699
+ // Chain depth: rows written before the hop column restore without it
2700
+ // (treated as a chain origin by importHistory — never as a bogus depth).
2701
+ ...typeof row.hop === "number" && Number.isFinite(row.hop) && row.hop > 0 ? { hop: row.hop } : {},
2666
2702
  ts
2667
2703
  };
2668
2704
  }
@@ -2677,11 +2713,11 @@ var SQLiteStateStore = class {
2677
2713
  );
2678
2714
  CREATE TABLE IF NOT EXISTS messages (
2679
2715
  seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2680
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
2716
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL, hop INTEGER
2681
2717
  );
2682
2718
  CREATE TABLE IF NOT EXISTS mailboxes (
2683
2719
  peer TEXT NOT NULL, seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, from_peer TEXT NOT NULL,
2684
- to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL
2720
+ to_peer TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, ref TEXT, channel TEXT, thread_id TEXT, ts INTEGER NOT NULL, hop INTEGER
2685
2721
  );
2686
2722
  CREATE TABLE IF NOT EXISTS groups (
2687
2723
  id TEXT PRIMARY KEY, name TEXT, members TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL
@@ -2691,9 +2727,11 @@ var SQLiteStateStore = class {
2691
2727
  );
2692
2728
  `);
2693
2729
  for (const table of ["messages", "mailboxes"]) {
2694
- try {
2695
- this.db.exec(`ALTER TABLE ${table} ADD COLUMN thread_id TEXT`);
2696
- } catch {
2730
+ for (const column of ["thread_id TEXT", "hop INTEGER"]) {
2731
+ try {
2732
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column}`);
2733
+ } catch {
2734
+ }
2697
2735
  }
2698
2736
  }
2699
2737
  log.info(`sqlite state open: ${file}`);
@@ -2707,14 +2745,14 @@ var SQLiteStateStore = class {
2707
2745
  /** Insert buffered messages and re-snapshot mailboxes/profiles/groups/tasks. */
2708
2746
  flush(snapshot) {
2709
2747
  const insert = this.db.prepare(
2710
- "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
2748
+ "INSERT INTO messages (id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts, hop) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2711
2749
  );
2712
2750
  for (const message of this.buffer.splice(0, this.buffer.length)) {
2713
2751
  insert.run(...messageToRow(message));
2714
2752
  }
2715
2753
  this.db.exec("DELETE FROM mailboxes");
2716
2754
  const insertMail = this.db.prepare(
2717
- "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2755
+ "INSERT INTO mailboxes (peer, id, from_peer, to_peer, kind, content, ref, channel, thread_id, ts, hop) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
2718
2756
  );
2719
2757
  for (const [peer, queue] of Object.entries(snapshot.mailboxes)) {
2720
2758
  for (const message of queue) insertMail.run(peer, ...messageToRow(message));
@@ -2807,7 +2845,7 @@ var SQLiteStateStore = class {
2807
2845
 
2808
2846
  // src/index.ts
2809
2847
  var SERVER_NAME = "agent-comm-hub";
2810
- var SERVER_VERSION = "0.9.0";
2848
+ var SERVER_VERSION = "0.9.1";
2811
2849
  var DEFAULT_HOST = "127.0.0.1";
2812
2850
  var DEFAULT_PORT = 18764;
2813
2851
  var DEFAULT_PATH = "/mcp";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-comm-hub",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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",