agent-comm-hub 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/README.zh.md +243 -0
- package/agents/README.md +49 -0
- package/agents/SKILL.md +62 -0
- package/agents/claude-code/.mcp.json +8 -0
- package/agents/codex/config.toml +3 -0
- package/agents/dsh/cordis.patch.yml +7 -0
- package/agents/gemini-cli/settings.json +8 -0
- package/agents/install-all.ps1 +177 -0
- package/agents/kimi-code/mcp-entry.json +10 -0
- package/agents/minimax-code/SKILL.md +62 -0
- package/agents/minimax-code/install-mcode.ps1 +121 -0
- package/agents/minimax-code/mcp-entry.json +10 -0
- package/agents/opencode/opencode.json +9 -0
- package/assets/agent-hub-banner-cn.png +0 -0
- package/assets/agent-hub-banner.png +0 -0
- package/lib/cli.js +1239 -0
- package/lib/index.js +798 -0
- package/lib/setup.js +168 -0
- package/package.json +46 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
// src/hub.ts
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// src/protocol.ts
|
|
8
|
+
var KINDS = ["chat", "task", "notice", "ack"];
|
|
9
|
+
var PEER_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
10
|
+
var BROADCAST = "all";
|
|
11
|
+
function encodeContent(payload) {
|
|
12
|
+
return JSON.stringify(payload);
|
|
13
|
+
}
|
|
14
|
+
function decodeContent(kind, content) {
|
|
15
|
+
if (kind !== "task" && kind !== "ack") return content;
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(content);
|
|
18
|
+
if (parsed !== null && typeof parsed === "object") return parsed;
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
return content;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/hub.ts
|
|
25
|
+
var AgentHub = class {
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
const idle = options.peerIdleTimeoutMs ?? 6e5;
|
|
29
|
+
if (idle > 0) {
|
|
30
|
+
const interval = Math.min(6e4, Math.max(1e3, idle / 2));
|
|
31
|
+
this.gcTimer = setInterval(() => this.gcTick(idle), interval);
|
|
32
|
+
this.gcTimer.unref?.();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
queues = /* @__PURE__ */ new Map();
|
|
36
|
+
waiters = /* @__PURE__ */ new Map();
|
|
37
|
+
historyRing = [];
|
|
38
|
+
lastSeen = /* @__PURE__ */ new Map();
|
|
39
|
+
gcTimer;
|
|
40
|
+
/** Stop the idle GC (call when the hub shuts down). */
|
|
41
|
+
dispose() {
|
|
42
|
+
if (this.gcTimer !== void 0) clearInterval(this.gcTimer);
|
|
43
|
+
}
|
|
44
|
+
gcTick(idleTimeoutMs) {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
for (const peerId of this.peers()) {
|
|
47
|
+
if (now - (this.lastSeen.get(peerId) ?? 0) > idleTimeoutMs) {
|
|
48
|
+
if (this.options.isPeerLive?.(peerId) === true) continue;
|
|
49
|
+
this.options.onPeerGc?.(peerId);
|
|
50
|
+
this.unregister(peerId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** All registered peer ids, insertion order. */
|
|
55
|
+
peers() {
|
|
56
|
+
return [...this.lastSeen.keys()];
|
|
57
|
+
}
|
|
58
|
+
/** Register a peer; throws if the id is already taken. */
|
|
59
|
+
register(peerId) {
|
|
60
|
+
if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
|
|
61
|
+
this.lastSeen.set(peerId, Date.now());
|
|
62
|
+
this.options.onPeersChanged?.(this.peers());
|
|
63
|
+
}
|
|
64
|
+
/** Remove a peer and its queued messages; pending waiters resolve as timeouts. */
|
|
65
|
+
unregister(peerId) {
|
|
66
|
+
this.queues.delete(peerId);
|
|
67
|
+
for (const waiter of this.waiters.get(peerId) ?? []) {
|
|
68
|
+
clearTimeout(waiter.timer);
|
|
69
|
+
waiter.onAbort();
|
|
70
|
+
}
|
|
71
|
+
this.waiters.delete(peerId);
|
|
72
|
+
this.lastSeen.delete(peerId);
|
|
73
|
+
this.options.onPeersChanged?.(this.peers());
|
|
74
|
+
}
|
|
75
|
+
/** Mark activity for `peer` (called on every tool call from that peer). */
|
|
76
|
+
touch(peerId) {
|
|
77
|
+
if (this.lastSeen.has(peerId)) this.lastSeen.set(peerId, Date.now());
|
|
78
|
+
}
|
|
79
|
+
/** Is the peer currently registered? */
|
|
80
|
+
has(peerId) {
|
|
81
|
+
return this.lastSeen.has(peerId);
|
|
82
|
+
}
|
|
83
|
+
/** Is the peer "active" (tool/activity within the connected window)? */
|
|
84
|
+
isActive(peerId) {
|
|
85
|
+
const last = this.lastSeen.get(peerId);
|
|
86
|
+
return last !== void 0 && Date.now() - last < (this.options.connectedWindowMs ?? 3e4);
|
|
87
|
+
}
|
|
88
|
+
/** Send a chat/notice text message to `to` (or {@link BROADCAST}). */
|
|
89
|
+
send(from, to, kind, content) {
|
|
90
|
+
return this.route(from, to, kind, content);
|
|
91
|
+
}
|
|
92
|
+
/** Send a structured task message to `to` (or {@link BROADCAST}). */
|
|
93
|
+
sendTask(from, to, task) {
|
|
94
|
+
return this.route(from, to, "task", JSON.stringify(task));
|
|
95
|
+
}
|
|
96
|
+
/** Send an acknowledgement back to the sender of `ref`. */
|
|
97
|
+
sendAck(from, ref, ack) {
|
|
98
|
+
const original = this.historyRing.findLast((message) => message.id === ref);
|
|
99
|
+
if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
|
|
100
|
+
return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
|
|
101
|
+
}
|
|
102
|
+
/** Recent messages involving `peer` (inbound and outbound), newest first. */
|
|
103
|
+
history(peerId, limit) {
|
|
104
|
+
const filtered = this.historyRing.filter((message) => message.from === peerId || message.to === peerId || message.to === BROADCAST);
|
|
105
|
+
return filtered.slice(-Math.max(0, limit)).reverse();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Live summary for the status tool. `livePeers` (sessions with a live SSE
|
|
109
|
+
* stream) count as connected even without recent tool activity.
|
|
110
|
+
*/
|
|
111
|
+
status(livePeers) {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
return {
|
|
114
|
+
server: "agent-comm-hub",
|
|
115
|
+
peers: this.peers().map((peer) => ({
|
|
116
|
+
id: peer,
|
|
117
|
+
connected: this.isActive(peer) || livePeers?.has(peer) === true,
|
|
118
|
+
lastSeenMs: this.lastSeen.get(peer) ?? 0,
|
|
119
|
+
queued: (this.queues.get(peer) ?? []).length,
|
|
120
|
+
waiting: (this.waiters.get(peer) ?? []).length
|
|
121
|
+
})),
|
|
122
|
+
historyLimit: this.options.historyLimit,
|
|
123
|
+
maxQueue: this.options.maxQueue
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Non-blocking drain of everything queued for `peer` (optionally from one sender). */
|
|
127
|
+
poll(peerId, from) {
|
|
128
|
+
const queue = this.queues.get(peerId) ?? [];
|
|
129
|
+
const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
|
|
130
|
+
for (const message of drained) this.remember(message);
|
|
131
|
+
return drained;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Long-poll for the next message addressed to `peer`: resolves immediately
|
|
135
|
+
* when a matching one is queued, otherwise waits up to `timeoutMs` (capped
|
|
136
|
+
* by `waitTimeoutMs`) or until `signal` aborts. `from` narrows to one sender.
|
|
137
|
+
*/
|
|
138
|
+
wait(peerId, timeoutMs, from, signal) {
|
|
139
|
+
const startedAt = Date.now();
|
|
140
|
+
const queued = this.poll(peerId, from)[0];
|
|
141
|
+
if (queued) return Promise.resolve({ type: "message", message: queued });
|
|
142
|
+
const budget = Math.max(1, Math.min(Math.floor(timeoutMs), this.options.waitTimeoutMs));
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
let settled = false;
|
|
145
|
+
const settle = (result) => {
|
|
146
|
+
if (settled) return;
|
|
147
|
+
settled = true;
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
signal?.removeEventListener("abort", onAbort);
|
|
150
|
+
resolve(result);
|
|
151
|
+
};
|
|
152
|
+
const onAbort = () => {
|
|
153
|
+
removeFromRegistry();
|
|
154
|
+
settle({ type: "timeout", waitedMs: Date.now() - startedAt });
|
|
155
|
+
};
|
|
156
|
+
const removeFromRegistry = () => {
|
|
157
|
+
const list2 = this.waiters.get(peerId);
|
|
158
|
+
if (list2) this.waiters.set(peerId, list2.filter((waiter) => waiter.resolve !== settle));
|
|
159
|
+
};
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
removeFromRegistry();
|
|
162
|
+
settle({ type: "timeout", waitedMs: Date.now() - startedAt });
|
|
163
|
+
}, budget);
|
|
164
|
+
if (signal?.aborted) {
|
|
165
|
+
onAbort();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
169
|
+
const list = this.waiters.get(peerId) ?? [];
|
|
170
|
+
list.push({ resolve: settle, timer, onAbort, ...from !== void 0 ? { from } : {} });
|
|
171
|
+
this.waiters.set(peerId, list);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** Create a message from `from` addressed to `to` and deliver it. */
|
|
175
|
+
route(from, to, kind, content, ref) {
|
|
176
|
+
if (!this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
|
|
177
|
+
if (to !== BROADCAST && !this.lastSeen.has(to)) throw new Error(`unknown recipient: ${to} (registered peers: ${this.peers().join(", ") || "none"})`);
|
|
178
|
+
const message = {
|
|
179
|
+
id: randomUUID(),
|
|
180
|
+
from,
|
|
181
|
+
to,
|
|
182
|
+
kind,
|
|
183
|
+
content,
|
|
184
|
+
...ref !== void 0 ? { ref } : {},
|
|
185
|
+
ts: Date.now()
|
|
186
|
+
};
|
|
187
|
+
this.lastSeen.set(from, message.ts);
|
|
188
|
+
if (to === BROADCAST) {
|
|
189
|
+
for (const peer of this.peers()) {
|
|
190
|
+
if (peer !== from) this.deliver(peer, message);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
this.deliver(to, message);
|
|
194
|
+
}
|
|
195
|
+
return message;
|
|
196
|
+
}
|
|
197
|
+
/** Queue or hand off a message; wake the first matching waiter for its target. */
|
|
198
|
+
deliver(target, message) {
|
|
199
|
+
const list = this.waiters.get(target) ?? [];
|
|
200
|
+
const index = list.findIndex((waiter) => waiter.from === void 0 || waiter.from === message.from);
|
|
201
|
+
if (index >= 0) {
|
|
202
|
+
const [waiter] = list.splice(index, 1);
|
|
203
|
+
this.waiters.set(target, list);
|
|
204
|
+
clearTimeout(waiter.timer);
|
|
205
|
+
this.remember(message);
|
|
206
|
+
waiter.resolve({ type: "message", message });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const queue = this.queues.get(target) ?? [];
|
|
210
|
+
if (queue.length >= this.options.maxQueue) queue.shift();
|
|
211
|
+
queue.push(message);
|
|
212
|
+
this.queues.set(target, queue);
|
|
213
|
+
this.options.onQueued?.(message);
|
|
214
|
+
}
|
|
215
|
+
/** Append a delivered message to the history ring. */
|
|
216
|
+
remember(message) {
|
|
217
|
+
this.historyRing.push(message);
|
|
218
|
+
if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
function drainFrom(queue, from) {
|
|
222
|
+
const kept = [];
|
|
223
|
+
const drained = [];
|
|
224
|
+
for (const message of queue) {
|
|
225
|
+
if (message.from === from) drained.push(message);
|
|
226
|
+
else kept.push(message);
|
|
227
|
+
}
|
|
228
|
+
queue.splice(0, queue.length, ...kept);
|
|
229
|
+
return drained;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/hub-tools.ts
|
|
233
|
+
var DEFAULT_WAIT_MS = 3e4;
|
|
234
|
+
function present(message) {
|
|
235
|
+
const { ref, ...rest } = message;
|
|
236
|
+
return {
|
|
237
|
+
...rest,
|
|
238
|
+
...ref !== void 0 ? { ref } : {},
|
|
239
|
+
content: decodeContent(message.kind, message.content)
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function sanitizePeerId(name) {
|
|
243
|
+
const cleaned = name.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
244
|
+
return cleaned === "" ? "agent" : cleaned;
|
|
245
|
+
}
|
|
246
|
+
function autoRegisterPeer(hub, registry, sessionId, clientName) {
|
|
247
|
+
if (sessionId === void 0) return void 0;
|
|
248
|
+
const bound = registry.peerFor(sessionId);
|
|
249
|
+
if (bound !== void 0) return bound;
|
|
250
|
+
if (registry.isSuppressed(sessionId)) return void 0;
|
|
251
|
+
const peerId = sanitizePeerId(clientName ?? "agent");
|
|
252
|
+
if (!hub.has(peerId)) hub.register(peerId);
|
|
253
|
+
registry.bindPeer(sessionId, peerId);
|
|
254
|
+
hub.touch(peerId);
|
|
255
|
+
return peerId;
|
|
256
|
+
}
|
|
257
|
+
function livePeersFor(registry) {
|
|
258
|
+
const live = /* @__PURE__ */ new Set();
|
|
259
|
+
const streams = registry.liveSessions();
|
|
260
|
+
for (const [sessionId, peerId] of registry.peerBindings) {
|
|
261
|
+
if (streams.has(sessionId)) live.add(peerId);
|
|
262
|
+
}
|
|
263
|
+
return live;
|
|
264
|
+
}
|
|
265
|
+
function hubTools(hub, registry, options) {
|
|
266
|
+
const schema = (properties, required = []) => ({
|
|
267
|
+
type: "object",
|
|
268
|
+
properties,
|
|
269
|
+
required,
|
|
270
|
+
additionalProperties: false
|
|
271
|
+
});
|
|
272
|
+
const str = (description) => ({ type: "string", description });
|
|
273
|
+
const int = (description) => ({ type: "integer", description });
|
|
274
|
+
const optStr = str;
|
|
275
|
+
const requirePeer = (sessionId) => {
|
|
276
|
+
const bound = registry.peerFor(sessionId);
|
|
277
|
+
if (bound !== void 0) {
|
|
278
|
+
hub.touch(bound);
|
|
279
|
+
return bound;
|
|
280
|
+
}
|
|
281
|
+
const auto = autoRegisterPeer(hub, registry, sessionId, registry.clientName(sessionId));
|
|
282
|
+
if (auto !== void 0) {
|
|
283
|
+
hub.touch(auto);
|
|
284
|
+
return auto;
|
|
285
|
+
}
|
|
286
|
+
throw new Error("not registered \u2014 call bridge_register(peerId) first");
|
|
287
|
+
};
|
|
288
|
+
const receipt = (message) => ({
|
|
289
|
+
ok: true,
|
|
290
|
+
id: message.id,
|
|
291
|
+
from: message.from,
|
|
292
|
+
to: message.to,
|
|
293
|
+
kind: message.kind,
|
|
294
|
+
ts: message.ts
|
|
295
|
+
});
|
|
296
|
+
const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
|
|
297
|
+
const wrap = (peerAware, handler) => async (args, sessionId) => {
|
|
298
|
+
const peer = peerAware ? requirePeer(sessionId) : "";
|
|
299
|
+
return handler(args, peer, sessionId);
|
|
300
|
+
};
|
|
301
|
+
return [
|
|
302
|
+
{
|
|
303
|
+
name: "bridge_register",
|
|
304
|
+
description: 'Claim or rename your identity on the hub. Sessions auto-share a peer id derived from the client name; call this to switch to a readable unique peerId such as "mavis" or "opencode:myproject". Rejects when the id is claimed by another connection. Returns the current peer list.',
|
|
305
|
+
inputSchema: schema({ peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars.") }, ["peerId"]),
|
|
306
|
+
handler: async (args, sessionId) => {
|
|
307
|
+
const peerId = String(args.peerId);
|
|
308
|
+
if (!/^[A-Za-z0-9._:-]{1,64}$/.test(peerId)) {
|
|
309
|
+
throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
310
|
+
}
|
|
311
|
+
if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
|
|
312
|
+
throw new Error(`peer already registered by another connection: ${peerId}`);
|
|
313
|
+
}
|
|
314
|
+
const current = registry.peerFor(sessionId);
|
|
315
|
+
if (current !== void 0 && current !== peerId) {
|
|
316
|
+
registry.unbindPeer(sessionId);
|
|
317
|
+
if (registry.attachedCount(current) === 0) hub.unregister(current);
|
|
318
|
+
}
|
|
319
|
+
registry.bindPeer(sessionId, peerId);
|
|
320
|
+
if (!hub.has(peerId)) hub.register(peerId);
|
|
321
|
+
registry.clearSuppress(sessionId);
|
|
322
|
+
hub.touch(peerId);
|
|
323
|
+
return { ok: true, peerId, peers: hub.peers() };
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "bridge_unregister",
|
|
328
|
+
description: "Leave the hub: detaches your session (and drops the peer when no other session shares it); auto-registration stays off until an explicit bridge_register. Idempotent.",
|
|
329
|
+
inputSchema: schema({}),
|
|
330
|
+
handler: async (_args, sessionId) => {
|
|
331
|
+
const peer = registry.peerFor(sessionId);
|
|
332
|
+
if (peer !== void 0) {
|
|
333
|
+
registry.unbindPeer(sessionId);
|
|
334
|
+
if (registry.attachedCount(peer) === 0) hub.unregister(peer);
|
|
335
|
+
}
|
|
336
|
+
registry.suppressAuto(sessionId);
|
|
337
|
+
return { ok: true, peerId: peer ?? null };
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: "bridge_chat",
|
|
342
|
+
description: 'Send a chat message to another agent on the hub. Use bridge_wait (long-poll) or bridge_poll to receive replies. `to` is the target peerId, or "all" to broadcast.',
|
|
343
|
+
inputSchema: schema(
|
|
344
|
+
{ to: str('Target peerId, or "all" to broadcast.'), message: str("The message text.") },
|
|
345
|
+
["to", "message"]
|
|
346
|
+
),
|
|
347
|
+
handler: wrap(true, async (args, peer) => receipt(hub.send(peer, String(args.to), "chat", String(args.message))))
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
name: "bridge_task",
|
|
351
|
+
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.",
|
|
352
|
+
inputSchema: schema(
|
|
353
|
+
{
|
|
354
|
+
to: str('Target peerId, or "all" to broadcast.'),
|
|
355
|
+
prompt: str("What the receiving agent should do."),
|
|
356
|
+
context: optStr("Optional background information for the task."),
|
|
357
|
+
deliverable: optStr("Optional expected deliverable description.")
|
|
358
|
+
},
|
|
359
|
+
["to", "prompt"]
|
|
360
|
+
),
|
|
361
|
+
handler: wrap(true, async (args, peer) => receipt(hub.sendTask(peer, String(args.to), {
|
|
362
|
+
prompt: String(args.prompt),
|
|
363
|
+
...args.context !== void 0 ? { context: String(args.context) } : {},
|
|
364
|
+
...args.deliverable !== void 0 ? { deliverable: String(args.deliverable) } : {}
|
|
365
|
+
})))
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: "bridge_ack",
|
|
369
|
+
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`.",
|
|
370
|
+
inputSchema: schema(
|
|
371
|
+
{
|
|
372
|
+
ref: str("The id of the message being acknowledged."),
|
|
373
|
+
status: { type: "string", enum: ["accepted", "rejected", "done", "failed"], description: "acknowledgement status." },
|
|
374
|
+
note: optStr("Optional explanation for the acknowledgement.")
|
|
375
|
+
},
|
|
376
|
+
["ref", "status"]
|
|
377
|
+
),
|
|
378
|
+
handler: wrap(true, async (args, peer) => {
|
|
379
|
+
const status = String(args.status);
|
|
380
|
+
const valid = ["accepted", "rejected", "done", "failed"];
|
|
381
|
+
if (!valid.includes(status)) {
|
|
382
|
+
throw new Error(`invalid ack status: ${status} (expected ${valid.join(" | ")})`);
|
|
383
|
+
}
|
|
384
|
+
return receipt(hub.sendAck(peer, String(args.ref), { status, ...args.note !== void 0 ? { note: String(args.note) } : {} }));
|
|
385
|
+
})
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "bridge_wait",
|
|
389
|
+
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.",
|
|
390
|
+
inputSchema: schema({
|
|
391
|
+
from: optStr("Only wait for messages from this peerId."),
|
|
392
|
+
timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`)
|
|
393
|
+
}),
|
|
394
|
+
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))))
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
name: "bridge_poll",
|
|
398
|
+
description: "Non-blocking: drain every message currently queued for you. Empty list when nothing is waiting. `from` narrows to one sender.",
|
|
399
|
+
inputSchema: schema({ from: optStr("Only drain messages from this peerId.") }),
|
|
400
|
+
handler: wrap(true, async (args, peer) => ({ messages: hub.poll(peer, args.from === void 0 ? void 0 : String(args.from)).map(present) }))
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
name: "bridge_status",
|
|
404
|
+
description: "Hub health: server info, every registered peer with connected/queued/waiting state. A peer is connected when it was active recently or its SSE channel is alive.",
|
|
405
|
+
inputSchema: schema({}),
|
|
406
|
+
handler: wrap(true, async () => hub.status(livePeersFor(registry)))
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: "bridge_peers",
|
|
410
|
+
description: "List registered peers and whether each is connected (recent activity or a live SSE channel).",
|
|
411
|
+
inputSchema: schema({}),
|
|
412
|
+
handler: wrap(true, async () => {
|
|
413
|
+
const status = hub.status(livePeersFor(registry));
|
|
414
|
+
return { peers: hub.peers().map((id) => ({ id, connected: status.peers.find((peer) => peer.id === id)?.connected ?? false })) };
|
|
415
|
+
})
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: "bridge_history",
|
|
419
|
+
description: "Recent messages involving you (newest first); pass `peer` to inspect another peer's conversation. Use to refresh context after a reconnect.",
|
|
420
|
+
inputSchema: schema({ peer: optStr("PeerId whose conversation to inspect; default: yourself."), limit: int("How many messages to return (default 20).") }),
|
|
421
|
+
handler: wrap(true, async (args, peer) => ({ messages: hub.history(args.peer === void 0 ? peer : String(args.peer), Math.min(args.limit === void 0 ? 20 : Number(args.limit), 100)).map(present) }))
|
|
422
|
+
}
|
|
423
|
+
];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/mcp-server.ts
|
|
427
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
428
|
+
var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
429
|
+
var LATEST_VERSION = SUPPORTED_VERSIONS[0];
|
|
430
|
+
var MAX_BODY_BYTES = 1048576;
|
|
431
|
+
var SessionRegistry = class {
|
|
432
|
+
sessions = /* @__PURE__ */ new Set();
|
|
433
|
+
/** sessionId → peerId claimed via `bridge_register`. */
|
|
434
|
+
peerBindings = /* @__PURE__ */ new Map();
|
|
435
|
+
/** Session id from the request header, if any. */
|
|
436
|
+
sessionIdFor(req) {
|
|
437
|
+
return req.headers["mcp-session-id"];
|
|
438
|
+
}
|
|
439
|
+
/** Track `sessionId`, generating a fresh one when absent. */
|
|
440
|
+
ensureSession(sessionId) {
|
|
441
|
+
const id = sessionId ?? randomUUID2();
|
|
442
|
+
this.sessions.add(id);
|
|
443
|
+
return id;
|
|
444
|
+
}
|
|
445
|
+
/** Peer bound to a session, if any. */
|
|
446
|
+
peerFor(sessionId) {
|
|
447
|
+
if (sessionId === void 0) return void 0;
|
|
448
|
+
return this.peerBindings.get(sessionId);
|
|
449
|
+
}
|
|
450
|
+
/** Bind `peerId` to `sessionId`; rejects when the session already claimed
|
|
451
|
+
* a different peer or the session id is absent. */
|
|
452
|
+
bindPeer(sessionId, peerId) {
|
|
453
|
+
if (sessionId === void 0) throw new Error("no MCP session \u2014 re-initialize the connection");
|
|
454
|
+
const existing = this.peerBindings.get(sessionId);
|
|
455
|
+
if (existing !== void 0 && existing !== peerId) {
|
|
456
|
+
throw new Error(`this connection is already registered as '${existing}'`);
|
|
457
|
+
}
|
|
458
|
+
this.peerBindings.set(sessionId, peerId);
|
|
459
|
+
this.sessions.add(sessionId);
|
|
460
|
+
return sessionId;
|
|
461
|
+
}
|
|
462
|
+
/** Drop the binding of a session (used by bridge_unregister). */
|
|
463
|
+
unbindPeer(sessionId) {
|
|
464
|
+
if (sessionId !== void 0) this.peerBindings.delete(sessionId);
|
|
465
|
+
}
|
|
466
|
+
/** Client-reported name per session (from the initialize clientInfo). */
|
|
467
|
+
clientNames = /* @__PURE__ */ new Map();
|
|
468
|
+
/** Remember the client name reported by a session (on initialize). */
|
|
469
|
+
noteClient(sessionId, name) {
|
|
470
|
+
this.clientNames.set(sessionId, name);
|
|
471
|
+
}
|
|
472
|
+
/** Client-reported name for a session, if any. */
|
|
473
|
+
clientName(sessionId) {
|
|
474
|
+
if (sessionId === void 0) return void 0;
|
|
475
|
+
return this.clientNames.get(sessionId);
|
|
476
|
+
}
|
|
477
|
+
/** Sessions whose owner explicitly unregistered; auto-registration is
|
|
478
|
+
* suppressed for them until an explicit `bridge_register`. */
|
|
479
|
+
suppressedAuto = /* @__PURE__ */ new Set();
|
|
480
|
+
/** Suppress auto-registration for this session (on bridge_unregister). */
|
|
481
|
+
suppressAuto(sessionId) {
|
|
482
|
+
if (sessionId !== void 0) this.suppressedAuto.add(sessionId);
|
|
483
|
+
}
|
|
484
|
+
/** Allow auto-registration again (on explicit bridge_register). */
|
|
485
|
+
clearSuppress(sessionId) {
|
|
486
|
+
if (sessionId !== void 0) this.suppressedAuto.delete(sessionId);
|
|
487
|
+
}
|
|
488
|
+
/** Whether this session may not auto-register. */
|
|
489
|
+
isSuppressed(sessionId) {
|
|
490
|
+
return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
|
|
491
|
+
}
|
|
492
|
+
/** Sessions with a live SSE stream (server→client channel). */
|
|
493
|
+
liveStreams = /* @__PURE__ */ new Set();
|
|
494
|
+
/** Mark a session's SSE stream open (server→client channel alive). */
|
|
495
|
+
markSseOpen(sessionId) {
|
|
496
|
+
this.liveStreams.add(sessionId);
|
|
497
|
+
}
|
|
498
|
+
/** Mark a session's SSE stream closed. */
|
|
499
|
+
markSseClosed(sessionId) {
|
|
500
|
+
this.liveStreams.delete(sessionId);
|
|
501
|
+
}
|
|
502
|
+
/** Session ids with a live SSE stream. */
|
|
503
|
+
liveSessions() {
|
|
504
|
+
return this.liveStreams;
|
|
505
|
+
}
|
|
506
|
+
/** Drop every binding that points at `peerId` (used by the idle GC). */
|
|
507
|
+
unbindPeerId(peerId) {
|
|
508
|
+
for (const [sessionId, bound] of this.peerBindings) {
|
|
509
|
+
if (bound === peerId) this.peerBindings.delete(sessionId);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/** How many sessions are currently attached to `peerId`. */
|
|
513
|
+
attachedCount(peerId) {
|
|
514
|
+
let count = 0;
|
|
515
|
+
for (const bound of this.peerBindings.values()) {
|
|
516
|
+
if (bound === peerId) count++;
|
|
517
|
+
}
|
|
518
|
+
return count;
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
var McpStreamableHttpServer = class {
|
|
522
|
+
constructor(tools, info, registry, log = () => {
|
|
523
|
+
}, onInitialize = () => {
|
|
524
|
+
}) {
|
|
525
|
+
this.tools = tools;
|
|
526
|
+
this.info = info;
|
|
527
|
+
this.registry = registry;
|
|
528
|
+
this.log = log;
|
|
529
|
+
this.onInitialize = onInitialize;
|
|
530
|
+
}
|
|
531
|
+
sseStreams = /* @__PURE__ */ new Map();
|
|
532
|
+
/** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
|
|
533
|
+
attach(server, path) {
|
|
534
|
+
server.on("request", (req, res) => {
|
|
535
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
536
|
+
if (url.pathname !== path) {
|
|
537
|
+
res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (req.method === "GET") {
|
|
541
|
+
this.handleGet(req, res);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (req.method === "POST") {
|
|
545
|
+
void this.handlePost(req, res);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (req.method === "OPTIONS") {
|
|
549
|
+
res.writeHead(204, corsHeaders()).end();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
res.writeHead(405, corsHeaders()).end();
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
/** Close all open SSE streams (called on server shutdown). */
|
|
556
|
+
close() {
|
|
557
|
+
for (const stream of this.sseStreams.values()) stream.end();
|
|
558
|
+
this.sseStreams.clear();
|
|
559
|
+
}
|
|
560
|
+
handleGet(req, res) {
|
|
561
|
+
const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
|
|
562
|
+
res.writeHead(200, {
|
|
563
|
+
...corsHeaders(),
|
|
564
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
565
|
+
"Cache-Control": "no-cache",
|
|
566
|
+
Connection: "keep-alive",
|
|
567
|
+
...req.headers["mcp-session-id"] ? {} : { "Mcp-Session-Id": sessionId }
|
|
568
|
+
});
|
|
569
|
+
res.write(": connected\n\n");
|
|
570
|
+
this.sseStreams.set(sessionId, res);
|
|
571
|
+
this.registry.markSseOpen(sessionId);
|
|
572
|
+
req.on("close", () => {
|
|
573
|
+
this.sseStreams.delete(sessionId);
|
|
574
|
+
this.registry.markSseClosed(sessionId);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
async handlePost(req, res) {
|
|
578
|
+
let body;
|
|
579
|
+
try {
|
|
580
|
+
body = await readBody(req);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
this.jsonRpcError(res, null, -32700, `parse error: ${error.message}`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
let message;
|
|
586
|
+
try {
|
|
587
|
+
message = JSON.parse(body.toString("utf8"));
|
|
588
|
+
} catch {
|
|
589
|
+
this.jsonRpcError(res, null, -32700, "parse error: invalid JSON");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (typeof message !== "object" || message === null || message.method === void 0) {
|
|
593
|
+
this.jsonRpcError(res, message?.id ?? null, -32600, "invalid request");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const id = message.id ?? null;
|
|
597
|
+
const sessionId = this.registry.sessionIdFor(req);
|
|
598
|
+
if (id === null) {
|
|
599
|
+
res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
const { result, extraHeaders } = await this.dispatch(message, sessionId);
|
|
604
|
+
res.writeHead(200, {
|
|
605
|
+
...corsHeaders(),
|
|
606
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
607
|
+
"Mcp-Protocol-Version": messageProtocolVersion(req),
|
|
608
|
+
...extraHeaders
|
|
609
|
+
});
|
|
610
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
611
|
+
} catch (error) {
|
|
612
|
+
const code = error.code ?? -32603;
|
|
613
|
+
this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async dispatch(message, sessionId) {
|
|
617
|
+
const method = message.method ?? "";
|
|
618
|
+
switch (method) {
|
|
619
|
+
case "initialize": {
|
|
620
|
+
const newSessionId = this.registry.ensureSession(sessionId);
|
|
621
|
+
const clientInfo = message.params?.clientInfo;
|
|
622
|
+
const clientName = typeof clientInfo?.name === "string" && clientInfo.name !== "" ? clientInfo.name : void 0;
|
|
623
|
+
if (clientName !== void 0) {
|
|
624
|
+
this.registry.noteClient(newSessionId, clientName);
|
|
625
|
+
}
|
|
626
|
+
this.onInitialize(newSessionId, clientName);
|
|
627
|
+
const requested = message.params?.protocolVersion;
|
|
628
|
+
const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
|
|
629
|
+
return {
|
|
630
|
+
extraHeaders: { "Mcp-Session-Id": newSessionId },
|
|
631
|
+
result: {
|
|
632
|
+
protocolVersion,
|
|
633
|
+
capabilities: { tools: { listChanged: false } },
|
|
634
|
+
serverInfo: { name: this.info.name, version: this.info.version },
|
|
635
|
+
instructions: "You are already registered with the hub (auto-registered at connect). Use bridge_chat / bridge_task / bridge_wait / bridge_poll / bridge_status / bridge_peers / bridge_history / bridge_ack to talk to other agents; bridge_register(peerId) renames your identity."
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
case "tools/list":
|
|
640
|
+
return { result: { tools: this.tools.map((tool) => ({
|
|
641
|
+
name: tool.name,
|
|
642
|
+
description: tool.description,
|
|
643
|
+
inputSchema: tool.inputSchema
|
|
644
|
+
})) } };
|
|
645
|
+
case "tools/call": {
|
|
646
|
+
const params = message.params ?? {};
|
|
647
|
+
if (typeof params.name !== "string") throw rpcError(-32602, "tools/call requires a string name");
|
|
648
|
+
const tool = this.tools.find((candidate) => candidate.name === params.name);
|
|
649
|
+
if (!tool) throw rpcError(-32602, `unknown tool: ${params.name}`);
|
|
650
|
+
const args = params.arguments ?? {};
|
|
651
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
652
|
+
throw rpcError(-32602, "tools/call arguments must be an object");
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
const value = await tool.handler(args, sessionId);
|
|
656
|
+
return { result: { content: [{ type: "text", text: JSON.stringify(value) }], isError: false } };
|
|
657
|
+
} catch (error) {
|
|
658
|
+
return {
|
|
659
|
+
result: {
|
|
660
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
661
|
+
isError: true
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
case "ping":
|
|
667
|
+
return { result: {} };
|
|
668
|
+
default:
|
|
669
|
+
throw rpcError(-32601, `method not found: ${method}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
jsonRpcError(res, id, code, message, data, protocolVersion) {
|
|
673
|
+
res.writeHead(code === -32700 || code === -32600 ? 400 : 200, {
|
|
674
|
+
...corsHeaders(),
|
|
675
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
676
|
+
...protocolVersion ? { "Mcp-Protocol-Version": protocolVersion } : {}
|
|
677
|
+
});
|
|
678
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message, ...data !== void 0 ? { data } : {} } }));
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
function rpcError(code, message) {
|
|
682
|
+
const error = new Error(message);
|
|
683
|
+
error.code = code;
|
|
684
|
+
return error;
|
|
685
|
+
}
|
|
686
|
+
function messageProtocolVersion(req) {
|
|
687
|
+
return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
|
|
688
|
+
}
|
|
689
|
+
function corsHeaders() {
|
|
690
|
+
return {
|
|
691
|
+
"Access-Control-Allow-Origin": "*",
|
|
692
|
+
"Access-Control-Allow-Headers": "*",
|
|
693
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function readBody(req) {
|
|
697
|
+
return new Promise((resolve, reject) => {
|
|
698
|
+
const chunks = [];
|
|
699
|
+
let size = 0;
|
|
700
|
+
req.on("data", (chunk) => {
|
|
701
|
+
size += chunk.length;
|
|
702
|
+
if (size > MAX_BODY_BYTES) {
|
|
703
|
+
reject(new Error("request body too large"));
|
|
704
|
+
req.destroy();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
chunks.push(chunk);
|
|
708
|
+
});
|
|
709
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
710
|
+
req.on("error", reject);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/index.ts
|
|
715
|
+
var SERVER_NAME = "agent-comm-hub";
|
|
716
|
+
var SERVER_VERSION = "0.1.6";
|
|
717
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
718
|
+
var DEFAULT_PORT = 18764;
|
|
719
|
+
var DEFAULT_PATH = "/mcp";
|
|
720
|
+
var DEFAULT_CONFIG = {
|
|
721
|
+
host: DEFAULT_HOST,
|
|
722
|
+
port: DEFAULT_PORT,
|
|
723
|
+
path: DEFAULT_PATH,
|
|
724
|
+
maxQueue: 200,
|
|
725
|
+
historyLimit: 100,
|
|
726
|
+
waitTimeoutMs: 6e4,
|
|
727
|
+
defaultWaitMs: 3e4,
|
|
728
|
+
connectedWindowMs: 3e4,
|
|
729
|
+
peerIdleTimeoutMs: 6e5
|
|
730
|
+
};
|
|
731
|
+
function startHub(config = {}, log = console) {
|
|
732
|
+
const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
|
|
733
|
+
const resolved = { ...DEFAULT_CONFIG, ...overrides };
|
|
734
|
+
const hub = new AgentHub({
|
|
735
|
+
maxQueue: resolved.maxQueue,
|
|
736
|
+
historyLimit: resolved.historyLimit,
|
|
737
|
+
waitTimeoutMs: resolved.waitTimeoutMs,
|
|
738
|
+
connectedWindowMs: resolved.connectedWindowMs,
|
|
739
|
+
peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
|
|
740
|
+
onPeerGc: (peerId) => registry.unbindPeerId(peerId),
|
|
741
|
+
// The idle GC must never evict a peer whose session has a live SSE channel.
|
|
742
|
+
isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
|
|
743
|
+
});
|
|
744
|
+
const registry = new SessionRegistry();
|
|
745
|
+
const mcp = new McpStreamableHttpServer(
|
|
746
|
+
hubTools(hub, registry, { defaultWaitMs: resolved.defaultWaitMs, waitTimeoutMs: resolved.waitTimeoutMs }),
|
|
747
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
748
|
+
registry,
|
|
749
|
+
(message) => log.warn(message),
|
|
750
|
+
(sessionId, clientName) => {
|
|
751
|
+
try {
|
|
752
|
+
const peer = autoRegisterPeer(hub, registry, sessionId, clientName);
|
|
753
|
+
if (peer !== void 0) log.info(`peer joined: ${peer}`);
|
|
754
|
+
} catch (error) {
|
|
755
|
+
log.warn(`auto-register failed: ${error.message}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
);
|
|
759
|
+
const server = createServer();
|
|
760
|
+
mcp.attach(server, resolved.path);
|
|
761
|
+
server.on("error", (error) => log.warn(`hub http server error: ${error.message}`));
|
|
762
|
+
server.listen(resolved.port, resolved.host, () => {
|
|
763
|
+
log.info(`agent-comm-hub listening on http://${resolved.host}:${resolved.port}${resolved.path}`);
|
|
764
|
+
});
|
|
765
|
+
return {
|
|
766
|
+
hub,
|
|
767
|
+
registry,
|
|
768
|
+
server,
|
|
769
|
+
mcp,
|
|
770
|
+
close: () => {
|
|
771
|
+
hub.dispose();
|
|
772
|
+
mcp.close();
|
|
773
|
+
server.closeAllConnections?.();
|
|
774
|
+
server.close();
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
export {
|
|
779
|
+
AgentHub,
|
|
780
|
+
BROADCAST,
|
|
781
|
+
DEFAULT_CONFIG,
|
|
782
|
+
DEFAULT_HOST,
|
|
783
|
+
DEFAULT_PATH,
|
|
784
|
+
DEFAULT_PORT,
|
|
785
|
+
KINDS,
|
|
786
|
+
McpStreamableHttpServer,
|
|
787
|
+
PEER_ID_PATTERN,
|
|
788
|
+
SERVER_NAME,
|
|
789
|
+
SERVER_VERSION,
|
|
790
|
+
SessionRegistry,
|
|
791
|
+
autoRegisterPeer,
|
|
792
|
+
decodeContent,
|
|
793
|
+
encodeContent,
|
|
794
|
+
hubTools,
|
|
795
|
+
present,
|
|
796
|
+
sanitizePeerId,
|
|
797
|
+
startHub
|
|
798
|
+
};
|