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 +2 -2
- package/README.zh.md +1 -1
- package/agents/SKILL.md +10 -2
- package/lib/cli.js +64 -25
- package/lib/index.js +62 -24
- package/package.json +1 -1
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 (
|
|
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
|
|
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 # 测试套件(
|
|
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.
|
|
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
|
|
731
|
-
const timer = setTimeout(() => this.fireTaskTimeout(record.id),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1250
|
-
this.deliver(to, message);
|
|
1278
|
+
return;
|
|
1251
1279
|
}
|
|
1252
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
732
|
-
const timer = setTimeout(() => this.fireTaskTimeout(record.id),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1251
|
-
this.deliver(to, message);
|
|
1279
|
+
return;
|
|
1252
1280
|
}
|
|
1253
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
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.
|
|
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.
|
|
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",
|