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/cli.js
ADDED
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
|
|
6
|
+
// src/hub.ts
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
// src/protocol.ts
|
|
10
|
+
var BROADCAST = "all";
|
|
11
|
+
function decodeContent(kind, content) {
|
|
12
|
+
if (kind !== "task" && kind !== "ack") return content;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(content);
|
|
15
|
+
if (parsed !== null && typeof parsed === "object") return parsed;
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
return content;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/hub.ts
|
|
22
|
+
var AgentHub = class {
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
const idle = options.peerIdleTimeoutMs ?? 6e5;
|
|
26
|
+
if (idle > 0) {
|
|
27
|
+
const interval = Math.min(6e4, Math.max(1e3, idle / 2));
|
|
28
|
+
this.gcTimer = setInterval(() => this.gcTick(idle), interval);
|
|
29
|
+
this.gcTimer.unref?.();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
queues = /* @__PURE__ */ new Map();
|
|
33
|
+
waiters = /* @__PURE__ */ new Map();
|
|
34
|
+
historyRing = [];
|
|
35
|
+
lastSeen = /* @__PURE__ */ new Map();
|
|
36
|
+
gcTimer;
|
|
37
|
+
/** Stop the idle GC (call when the hub shuts down). */
|
|
38
|
+
dispose() {
|
|
39
|
+
if (this.gcTimer !== void 0) clearInterval(this.gcTimer);
|
|
40
|
+
}
|
|
41
|
+
gcTick(idleTimeoutMs) {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
for (const peerId of this.peers()) {
|
|
44
|
+
if (now - (this.lastSeen.get(peerId) ?? 0) > idleTimeoutMs) {
|
|
45
|
+
if (this.options.isPeerLive?.(peerId) === true) continue;
|
|
46
|
+
this.options.onPeerGc?.(peerId);
|
|
47
|
+
this.unregister(peerId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** All registered peer ids, insertion order. */
|
|
52
|
+
peers() {
|
|
53
|
+
return [...this.lastSeen.keys()];
|
|
54
|
+
}
|
|
55
|
+
/** Register a peer; throws if the id is already taken. */
|
|
56
|
+
register(peerId) {
|
|
57
|
+
if (this.lastSeen.has(peerId)) throw new Error(`peer already registered: ${peerId}`);
|
|
58
|
+
this.lastSeen.set(peerId, Date.now());
|
|
59
|
+
this.options.onPeersChanged?.(this.peers());
|
|
60
|
+
}
|
|
61
|
+
/** Remove a peer and its queued messages; pending waiters resolve as timeouts. */
|
|
62
|
+
unregister(peerId) {
|
|
63
|
+
this.queues.delete(peerId);
|
|
64
|
+
for (const waiter of this.waiters.get(peerId) ?? []) {
|
|
65
|
+
clearTimeout(waiter.timer);
|
|
66
|
+
waiter.onAbort();
|
|
67
|
+
}
|
|
68
|
+
this.waiters.delete(peerId);
|
|
69
|
+
this.lastSeen.delete(peerId);
|
|
70
|
+
this.options.onPeersChanged?.(this.peers());
|
|
71
|
+
}
|
|
72
|
+
/** Mark activity for `peer` (called on every tool call from that peer). */
|
|
73
|
+
touch(peerId) {
|
|
74
|
+
if (this.lastSeen.has(peerId)) this.lastSeen.set(peerId, Date.now());
|
|
75
|
+
}
|
|
76
|
+
/** Is the peer currently registered? */
|
|
77
|
+
has(peerId) {
|
|
78
|
+
return this.lastSeen.has(peerId);
|
|
79
|
+
}
|
|
80
|
+
/** Is the peer "active" (tool/activity within the connected window)? */
|
|
81
|
+
isActive(peerId) {
|
|
82
|
+
const last = this.lastSeen.get(peerId);
|
|
83
|
+
return last !== void 0 && Date.now() - last < (this.options.connectedWindowMs ?? 3e4);
|
|
84
|
+
}
|
|
85
|
+
/** Send a chat/notice text message to `to` (or {@link BROADCAST}). */
|
|
86
|
+
send(from, to, kind, content) {
|
|
87
|
+
return this.route(from, to, kind, content);
|
|
88
|
+
}
|
|
89
|
+
/** Send a structured task message to `to` (or {@link BROADCAST}). */
|
|
90
|
+
sendTask(from, to, task) {
|
|
91
|
+
return this.route(from, to, "task", JSON.stringify(task));
|
|
92
|
+
}
|
|
93
|
+
/** Send an acknowledgement back to the sender of `ref`. */
|
|
94
|
+
sendAck(from, ref, ack) {
|
|
95
|
+
const original = this.historyRing.findLast((message) => message.id === ref);
|
|
96
|
+
if (!original) throw new Error(`cannot ack unknown message: ${ref}`);
|
|
97
|
+
return this.route(from, original.from, "ack", JSON.stringify(ack), ref);
|
|
98
|
+
}
|
|
99
|
+
/** Recent messages involving `peer` (inbound and outbound), newest first. */
|
|
100
|
+
history(peerId, limit) {
|
|
101
|
+
const filtered = this.historyRing.filter((message) => message.from === peerId || message.to === peerId || message.to === BROADCAST);
|
|
102
|
+
return filtered.slice(-Math.max(0, limit)).reverse();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Live summary for the status tool. `livePeers` (sessions with a live SSE
|
|
106
|
+
* stream) count as connected even without recent tool activity.
|
|
107
|
+
*/
|
|
108
|
+
status(livePeers) {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
return {
|
|
111
|
+
server: "agent-comm-hub",
|
|
112
|
+
peers: this.peers().map((peer) => ({
|
|
113
|
+
id: peer,
|
|
114
|
+
connected: this.isActive(peer) || livePeers?.has(peer) === true,
|
|
115
|
+
lastSeenMs: this.lastSeen.get(peer) ?? 0,
|
|
116
|
+
queued: (this.queues.get(peer) ?? []).length,
|
|
117
|
+
waiting: (this.waiters.get(peer) ?? []).length
|
|
118
|
+
})),
|
|
119
|
+
historyLimit: this.options.historyLimit,
|
|
120
|
+
maxQueue: this.options.maxQueue
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Non-blocking drain of everything queued for `peer` (optionally from one sender). */
|
|
124
|
+
poll(peerId, from) {
|
|
125
|
+
const queue = this.queues.get(peerId) ?? [];
|
|
126
|
+
const drained = from === void 0 ? queue.splice(0, queue.length) : drainFrom(queue, from);
|
|
127
|
+
for (const message of drained) this.remember(message);
|
|
128
|
+
return drained;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Long-poll for the next message addressed to `peer`: resolves immediately
|
|
132
|
+
* when a matching one is queued, otherwise waits up to `timeoutMs` (capped
|
|
133
|
+
* by `waitTimeoutMs`) or until `signal` aborts. `from` narrows to one sender.
|
|
134
|
+
*/
|
|
135
|
+
wait(peerId, timeoutMs, from, signal) {
|
|
136
|
+
const startedAt = Date.now();
|
|
137
|
+
const queued = this.poll(peerId, from)[0];
|
|
138
|
+
if (queued) return Promise.resolve({ type: "message", message: queued });
|
|
139
|
+
const budget = Math.max(1, Math.min(Math.floor(timeoutMs), this.options.waitTimeoutMs));
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
let settled = false;
|
|
142
|
+
const settle = (result) => {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
signal?.removeEventListener("abort", onAbort);
|
|
147
|
+
resolve(result);
|
|
148
|
+
};
|
|
149
|
+
const onAbort = () => {
|
|
150
|
+
removeFromRegistry();
|
|
151
|
+
settle({ type: "timeout", waitedMs: Date.now() - startedAt });
|
|
152
|
+
};
|
|
153
|
+
const removeFromRegistry = () => {
|
|
154
|
+
const list2 = this.waiters.get(peerId);
|
|
155
|
+
if (list2) this.waiters.set(peerId, list2.filter((waiter) => waiter.resolve !== settle));
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
removeFromRegistry();
|
|
159
|
+
settle({ type: "timeout", waitedMs: Date.now() - startedAt });
|
|
160
|
+
}, budget);
|
|
161
|
+
if (signal?.aborted) {
|
|
162
|
+
onAbort();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
166
|
+
const list = this.waiters.get(peerId) ?? [];
|
|
167
|
+
list.push({ resolve: settle, timer, onAbort, ...from !== void 0 ? { from } : {} });
|
|
168
|
+
this.waiters.set(peerId, list);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Create a message from `from` addressed to `to` and deliver it. */
|
|
172
|
+
route(from, to, kind, content, ref) {
|
|
173
|
+
if (!this.lastSeen.has(from)) throw new Error(`sender not registered: ${from}`);
|
|
174
|
+
if (to !== BROADCAST && !this.lastSeen.has(to)) throw new Error(`unknown recipient: ${to} (registered peers: ${this.peers().join(", ") || "none"})`);
|
|
175
|
+
const message = {
|
|
176
|
+
id: randomUUID(),
|
|
177
|
+
from,
|
|
178
|
+
to,
|
|
179
|
+
kind,
|
|
180
|
+
content,
|
|
181
|
+
...ref !== void 0 ? { ref } : {},
|
|
182
|
+
ts: Date.now()
|
|
183
|
+
};
|
|
184
|
+
this.lastSeen.set(from, message.ts);
|
|
185
|
+
if (to === BROADCAST) {
|
|
186
|
+
for (const peer of this.peers()) {
|
|
187
|
+
if (peer !== from) this.deliver(peer, message);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
this.deliver(to, message);
|
|
191
|
+
}
|
|
192
|
+
return message;
|
|
193
|
+
}
|
|
194
|
+
/** Queue or hand off a message; wake the first matching waiter for its target. */
|
|
195
|
+
deliver(target, message) {
|
|
196
|
+
const list = this.waiters.get(target) ?? [];
|
|
197
|
+
const index = list.findIndex((waiter) => waiter.from === void 0 || waiter.from === message.from);
|
|
198
|
+
if (index >= 0) {
|
|
199
|
+
const [waiter] = list.splice(index, 1);
|
|
200
|
+
this.waiters.set(target, list);
|
|
201
|
+
clearTimeout(waiter.timer);
|
|
202
|
+
this.remember(message);
|
|
203
|
+
waiter.resolve({ type: "message", message });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const queue = this.queues.get(target) ?? [];
|
|
207
|
+
if (queue.length >= this.options.maxQueue) queue.shift();
|
|
208
|
+
queue.push(message);
|
|
209
|
+
this.queues.set(target, queue);
|
|
210
|
+
this.options.onQueued?.(message);
|
|
211
|
+
}
|
|
212
|
+
/** Append a delivered message to the history ring. */
|
|
213
|
+
remember(message) {
|
|
214
|
+
this.historyRing.push(message);
|
|
215
|
+
if (this.historyRing.length > this.options.historyLimit) this.historyRing.splice(0, this.historyRing.length - this.options.historyLimit);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
function drainFrom(queue, from) {
|
|
219
|
+
const kept = [];
|
|
220
|
+
const drained = [];
|
|
221
|
+
for (const message of queue) {
|
|
222
|
+
if (message.from === from) drained.push(message);
|
|
223
|
+
else kept.push(message);
|
|
224
|
+
}
|
|
225
|
+
queue.splice(0, queue.length, ...kept);
|
|
226
|
+
return drained;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/hub-tools.ts
|
|
230
|
+
var DEFAULT_WAIT_MS = 3e4;
|
|
231
|
+
function present(message) {
|
|
232
|
+
const { ref, ...rest } = message;
|
|
233
|
+
return {
|
|
234
|
+
...rest,
|
|
235
|
+
...ref !== void 0 ? { ref } : {},
|
|
236
|
+
content: decodeContent(message.kind, message.content)
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function sanitizePeerId(name) {
|
|
240
|
+
const cleaned = name.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
241
|
+
return cleaned === "" ? "agent" : cleaned;
|
|
242
|
+
}
|
|
243
|
+
function autoRegisterPeer(hub, registry, sessionId, clientName) {
|
|
244
|
+
if (sessionId === void 0) return void 0;
|
|
245
|
+
const bound = registry.peerFor(sessionId);
|
|
246
|
+
if (bound !== void 0) return bound;
|
|
247
|
+
if (registry.isSuppressed(sessionId)) return void 0;
|
|
248
|
+
const peerId = sanitizePeerId(clientName ?? "agent");
|
|
249
|
+
if (!hub.has(peerId)) hub.register(peerId);
|
|
250
|
+
registry.bindPeer(sessionId, peerId);
|
|
251
|
+
hub.touch(peerId);
|
|
252
|
+
return peerId;
|
|
253
|
+
}
|
|
254
|
+
function livePeersFor(registry) {
|
|
255
|
+
const live = /* @__PURE__ */ new Set();
|
|
256
|
+
const streams = registry.liveSessions();
|
|
257
|
+
for (const [sessionId, peerId] of registry.peerBindings) {
|
|
258
|
+
if (streams.has(sessionId)) live.add(peerId);
|
|
259
|
+
}
|
|
260
|
+
return live;
|
|
261
|
+
}
|
|
262
|
+
function hubTools(hub, registry, options) {
|
|
263
|
+
const schema = (properties, required = []) => ({
|
|
264
|
+
type: "object",
|
|
265
|
+
properties,
|
|
266
|
+
required,
|
|
267
|
+
additionalProperties: false
|
|
268
|
+
});
|
|
269
|
+
const str = (description) => ({ type: "string", description });
|
|
270
|
+
const int = (description) => ({ type: "integer", description });
|
|
271
|
+
const optStr = str;
|
|
272
|
+
const requirePeer = (sessionId) => {
|
|
273
|
+
const bound = registry.peerFor(sessionId);
|
|
274
|
+
if (bound !== void 0) {
|
|
275
|
+
hub.touch(bound);
|
|
276
|
+
return bound;
|
|
277
|
+
}
|
|
278
|
+
const auto = autoRegisterPeer(hub, registry, sessionId, registry.clientName(sessionId));
|
|
279
|
+
if (auto !== void 0) {
|
|
280
|
+
hub.touch(auto);
|
|
281
|
+
return auto;
|
|
282
|
+
}
|
|
283
|
+
throw new Error("not registered \u2014 call bridge_register(peerId) first");
|
|
284
|
+
};
|
|
285
|
+
const receipt = (message) => ({
|
|
286
|
+
ok: true,
|
|
287
|
+
id: message.id,
|
|
288
|
+
from: message.from,
|
|
289
|
+
to: message.to,
|
|
290
|
+
kind: message.kind,
|
|
291
|
+
ts: message.ts
|
|
292
|
+
});
|
|
293
|
+
const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
|
|
294
|
+
const wrap = (peerAware, handler) => async (args, sessionId) => {
|
|
295
|
+
const peer = peerAware ? requirePeer(sessionId) : "";
|
|
296
|
+
return handler(args, peer, sessionId);
|
|
297
|
+
};
|
|
298
|
+
return [
|
|
299
|
+
{
|
|
300
|
+
name: "bridge_register",
|
|
301
|
+
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.',
|
|
302
|
+
inputSchema: schema({ peerId: str("Unique peer id: letters/digits/._:- , 1-64 chars.") }, ["peerId"]),
|
|
303
|
+
handler: async (args, sessionId) => {
|
|
304
|
+
const peerId = String(args.peerId);
|
|
305
|
+
if (!/^[A-Za-z0-9._:-]{1,64}$/.test(peerId)) {
|
|
306
|
+
throw new Error(`invalid peerId: ${peerId} (expected [A-Za-z0-9._:-]{1,64})`);
|
|
307
|
+
}
|
|
308
|
+
if (hub.has(peerId) && registry.peerFor(sessionId) !== peerId) {
|
|
309
|
+
throw new Error(`peer already registered by another connection: ${peerId}`);
|
|
310
|
+
}
|
|
311
|
+
const current = registry.peerFor(sessionId);
|
|
312
|
+
if (current !== void 0 && current !== peerId) {
|
|
313
|
+
registry.unbindPeer(sessionId);
|
|
314
|
+
if (registry.attachedCount(current) === 0) hub.unregister(current);
|
|
315
|
+
}
|
|
316
|
+
registry.bindPeer(sessionId, peerId);
|
|
317
|
+
if (!hub.has(peerId)) hub.register(peerId);
|
|
318
|
+
registry.clearSuppress(sessionId);
|
|
319
|
+
hub.touch(peerId);
|
|
320
|
+
return { ok: true, peerId, peers: hub.peers() };
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: "bridge_unregister",
|
|
325
|
+
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.",
|
|
326
|
+
inputSchema: schema({}),
|
|
327
|
+
handler: async (_args, sessionId) => {
|
|
328
|
+
const peer = registry.peerFor(sessionId);
|
|
329
|
+
if (peer !== void 0) {
|
|
330
|
+
registry.unbindPeer(sessionId);
|
|
331
|
+
if (registry.attachedCount(peer) === 0) hub.unregister(peer);
|
|
332
|
+
}
|
|
333
|
+
registry.suppressAuto(sessionId);
|
|
334
|
+
return { ok: true, peerId: peer ?? null };
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: "bridge_chat",
|
|
339
|
+
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.',
|
|
340
|
+
inputSchema: schema(
|
|
341
|
+
{ to: str('Target peerId, or "all" to broadcast.'), message: str("The message text.") },
|
|
342
|
+
["to", "message"]
|
|
343
|
+
),
|
|
344
|
+
handler: wrap(true, async (args, peer) => receipt(hub.send(peer, String(args.to), "chat", String(args.message))))
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "bridge_task",
|
|
348
|
+
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.",
|
|
349
|
+
inputSchema: schema(
|
|
350
|
+
{
|
|
351
|
+
to: str('Target peerId, or "all" to broadcast.'),
|
|
352
|
+
prompt: str("What the receiving agent should do."),
|
|
353
|
+
context: optStr("Optional background information for the task."),
|
|
354
|
+
deliverable: optStr("Optional expected deliverable description.")
|
|
355
|
+
},
|
|
356
|
+
["to", "prompt"]
|
|
357
|
+
),
|
|
358
|
+
handler: wrap(true, async (args, peer) => receipt(hub.sendTask(peer, String(args.to), {
|
|
359
|
+
prompt: String(args.prompt),
|
|
360
|
+
...args.context !== void 0 ? { context: String(args.context) } : {},
|
|
361
|
+
...args.deliverable !== void 0 ? { deliverable: String(args.deliverable) } : {}
|
|
362
|
+
})))
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "bridge_ack",
|
|
366
|
+
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`.",
|
|
367
|
+
inputSchema: schema(
|
|
368
|
+
{
|
|
369
|
+
ref: str("The id of the message being acknowledged."),
|
|
370
|
+
status: { type: "string", enum: ["accepted", "rejected", "done", "failed"], description: "acknowledgement status." },
|
|
371
|
+
note: optStr("Optional explanation for the acknowledgement.")
|
|
372
|
+
},
|
|
373
|
+
["ref", "status"]
|
|
374
|
+
),
|
|
375
|
+
handler: wrap(true, async (args, peer) => {
|
|
376
|
+
const status = String(args.status);
|
|
377
|
+
const valid = ["accepted", "rejected", "done", "failed"];
|
|
378
|
+
if (!valid.includes(status)) {
|
|
379
|
+
throw new Error(`invalid ack status: ${status} (expected ${valid.join(" | ")})`);
|
|
380
|
+
}
|
|
381
|
+
return receipt(hub.sendAck(peer, String(args.ref), { status, ...args.note !== void 0 ? { note: String(args.note) } : {} }));
|
|
382
|
+
})
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
name: "bridge_wait",
|
|
386
|
+
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.",
|
|
387
|
+
inputSchema: schema({
|
|
388
|
+
from: optStr("Only wait for messages from this peerId."),
|
|
389
|
+
timeoutMs: int(`Max wait in milliseconds (default ${DEFAULT_WAIT_MS}, ceiling is server waitTimeoutMs).`)
|
|
390
|
+
}),
|
|
391
|
+
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))))
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
name: "bridge_poll",
|
|
395
|
+
description: "Non-blocking: drain every message currently queued for you. Empty list when nothing is waiting. `from` narrows to one sender.",
|
|
396
|
+
inputSchema: schema({ from: optStr("Only drain messages from this peerId.") }),
|
|
397
|
+
handler: wrap(true, async (args, peer) => ({ messages: hub.poll(peer, args.from === void 0 ? void 0 : String(args.from)).map(present) }))
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: "bridge_status",
|
|
401
|
+
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.",
|
|
402
|
+
inputSchema: schema({}),
|
|
403
|
+
handler: wrap(true, async () => hub.status(livePeersFor(registry)))
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
name: "bridge_peers",
|
|
407
|
+
description: "List registered peers and whether each is connected (recent activity or a live SSE channel).",
|
|
408
|
+
inputSchema: schema({}),
|
|
409
|
+
handler: wrap(true, async () => {
|
|
410
|
+
const status = hub.status(livePeersFor(registry));
|
|
411
|
+
return { peers: hub.peers().map((id) => ({ id, connected: status.peers.find((peer) => peer.id === id)?.connected ?? false })) };
|
|
412
|
+
})
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
name: "bridge_history",
|
|
416
|
+
description: "Recent messages involving you (newest first); pass `peer` to inspect another peer's conversation. Use to refresh context after a reconnect.",
|
|
417
|
+
inputSchema: schema({ peer: optStr("PeerId whose conversation to inspect; default: yourself."), limit: int("How many messages to return (default 20).") }),
|
|
418
|
+
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) }))
|
|
419
|
+
}
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/mcp-server.ts
|
|
424
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
425
|
+
var SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
426
|
+
var LATEST_VERSION = SUPPORTED_VERSIONS[0];
|
|
427
|
+
var MAX_BODY_BYTES = 1048576;
|
|
428
|
+
var SessionRegistry = class {
|
|
429
|
+
sessions = /* @__PURE__ */ new Set();
|
|
430
|
+
/** sessionId → peerId claimed via `bridge_register`. */
|
|
431
|
+
peerBindings = /* @__PURE__ */ new Map();
|
|
432
|
+
/** Session id from the request header, if any. */
|
|
433
|
+
sessionIdFor(req) {
|
|
434
|
+
return req.headers["mcp-session-id"];
|
|
435
|
+
}
|
|
436
|
+
/** Track `sessionId`, generating a fresh one when absent. */
|
|
437
|
+
ensureSession(sessionId) {
|
|
438
|
+
const id = sessionId ?? randomUUID2();
|
|
439
|
+
this.sessions.add(id);
|
|
440
|
+
return id;
|
|
441
|
+
}
|
|
442
|
+
/** Peer bound to a session, if any. */
|
|
443
|
+
peerFor(sessionId) {
|
|
444
|
+
if (sessionId === void 0) return void 0;
|
|
445
|
+
return this.peerBindings.get(sessionId);
|
|
446
|
+
}
|
|
447
|
+
/** Bind `peerId` to `sessionId`; rejects when the session already claimed
|
|
448
|
+
* a different peer or the session id is absent. */
|
|
449
|
+
bindPeer(sessionId, peerId) {
|
|
450
|
+
if (sessionId === void 0) throw new Error("no MCP session \u2014 re-initialize the connection");
|
|
451
|
+
const existing = this.peerBindings.get(sessionId);
|
|
452
|
+
if (existing !== void 0 && existing !== peerId) {
|
|
453
|
+
throw new Error(`this connection is already registered as '${existing}'`);
|
|
454
|
+
}
|
|
455
|
+
this.peerBindings.set(sessionId, peerId);
|
|
456
|
+
this.sessions.add(sessionId);
|
|
457
|
+
return sessionId;
|
|
458
|
+
}
|
|
459
|
+
/** Drop the binding of a session (used by bridge_unregister). */
|
|
460
|
+
unbindPeer(sessionId) {
|
|
461
|
+
if (sessionId !== void 0) this.peerBindings.delete(sessionId);
|
|
462
|
+
}
|
|
463
|
+
/** Client-reported name per session (from the initialize clientInfo). */
|
|
464
|
+
clientNames = /* @__PURE__ */ new Map();
|
|
465
|
+
/** Remember the client name reported by a session (on initialize). */
|
|
466
|
+
noteClient(sessionId, name) {
|
|
467
|
+
this.clientNames.set(sessionId, name);
|
|
468
|
+
}
|
|
469
|
+
/** Client-reported name for a session, if any. */
|
|
470
|
+
clientName(sessionId) {
|
|
471
|
+
if (sessionId === void 0) return void 0;
|
|
472
|
+
return this.clientNames.get(sessionId);
|
|
473
|
+
}
|
|
474
|
+
/** Sessions whose owner explicitly unregistered; auto-registration is
|
|
475
|
+
* suppressed for them until an explicit `bridge_register`. */
|
|
476
|
+
suppressedAuto = /* @__PURE__ */ new Set();
|
|
477
|
+
/** Suppress auto-registration for this session (on bridge_unregister). */
|
|
478
|
+
suppressAuto(sessionId) {
|
|
479
|
+
if (sessionId !== void 0) this.suppressedAuto.add(sessionId);
|
|
480
|
+
}
|
|
481
|
+
/** Allow auto-registration again (on explicit bridge_register). */
|
|
482
|
+
clearSuppress(sessionId) {
|
|
483
|
+
if (sessionId !== void 0) this.suppressedAuto.delete(sessionId);
|
|
484
|
+
}
|
|
485
|
+
/** Whether this session may not auto-register. */
|
|
486
|
+
isSuppressed(sessionId) {
|
|
487
|
+
return sessionId !== void 0 && this.suppressedAuto.has(sessionId);
|
|
488
|
+
}
|
|
489
|
+
/** Sessions with a live SSE stream (server→client channel). */
|
|
490
|
+
liveStreams = /* @__PURE__ */ new Set();
|
|
491
|
+
/** Mark a session's SSE stream open (server→client channel alive). */
|
|
492
|
+
markSseOpen(sessionId) {
|
|
493
|
+
this.liveStreams.add(sessionId);
|
|
494
|
+
}
|
|
495
|
+
/** Mark a session's SSE stream closed. */
|
|
496
|
+
markSseClosed(sessionId) {
|
|
497
|
+
this.liveStreams.delete(sessionId);
|
|
498
|
+
}
|
|
499
|
+
/** Session ids with a live SSE stream. */
|
|
500
|
+
liveSessions() {
|
|
501
|
+
return this.liveStreams;
|
|
502
|
+
}
|
|
503
|
+
/** Drop every binding that points at `peerId` (used by the idle GC). */
|
|
504
|
+
unbindPeerId(peerId) {
|
|
505
|
+
for (const [sessionId, bound] of this.peerBindings) {
|
|
506
|
+
if (bound === peerId) this.peerBindings.delete(sessionId);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** How many sessions are currently attached to `peerId`. */
|
|
510
|
+
attachedCount(peerId) {
|
|
511
|
+
let count = 0;
|
|
512
|
+
for (const bound of this.peerBindings.values()) {
|
|
513
|
+
if (bound === peerId) count++;
|
|
514
|
+
}
|
|
515
|
+
return count;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
var McpStreamableHttpServer = class {
|
|
519
|
+
constructor(tools, info, registry, log2 = () => {
|
|
520
|
+
}, onInitialize = () => {
|
|
521
|
+
}) {
|
|
522
|
+
this.tools = tools;
|
|
523
|
+
this.info = info;
|
|
524
|
+
this.registry = registry;
|
|
525
|
+
this.log = log2;
|
|
526
|
+
this.onInitialize = onInitialize;
|
|
527
|
+
}
|
|
528
|
+
sseStreams = /* @__PURE__ */ new Map();
|
|
529
|
+
/** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
|
|
530
|
+
attach(server, path) {
|
|
531
|
+
server.on("request", (req, res) => {
|
|
532
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
533
|
+
if (url.pathname !== path) {
|
|
534
|
+
res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (req.method === "GET") {
|
|
538
|
+
this.handleGet(req, res);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (req.method === "POST") {
|
|
542
|
+
void this.handlePost(req, res);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (req.method === "OPTIONS") {
|
|
546
|
+
res.writeHead(204, corsHeaders()).end();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
res.writeHead(405, corsHeaders()).end();
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/** Close all open SSE streams (called on server shutdown). */
|
|
553
|
+
close() {
|
|
554
|
+
for (const stream of this.sseStreams.values()) stream.end();
|
|
555
|
+
this.sseStreams.clear();
|
|
556
|
+
}
|
|
557
|
+
handleGet(req, res) {
|
|
558
|
+
const sessionId = this.registry.ensureSession(this.registry.sessionIdFor(req));
|
|
559
|
+
res.writeHead(200, {
|
|
560
|
+
...corsHeaders(),
|
|
561
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
562
|
+
"Cache-Control": "no-cache",
|
|
563
|
+
Connection: "keep-alive",
|
|
564
|
+
...req.headers["mcp-session-id"] ? {} : { "Mcp-Session-Id": sessionId }
|
|
565
|
+
});
|
|
566
|
+
res.write(": connected\n\n");
|
|
567
|
+
this.sseStreams.set(sessionId, res);
|
|
568
|
+
this.registry.markSseOpen(sessionId);
|
|
569
|
+
req.on("close", () => {
|
|
570
|
+
this.sseStreams.delete(sessionId);
|
|
571
|
+
this.registry.markSseClosed(sessionId);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
async handlePost(req, res) {
|
|
575
|
+
let body;
|
|
576
|
+
try {
|
|
577
|
+
body = await readBody(req);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
this.jsonRpcError(res, null, -32700, `parse error: ${error.message}`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
let message;
|
|
583
|
+
try {
|
|
584
|
+
message = JSON.parse(body.toString("utf8"));
|
|
585
|
+
} catch {
|
|
586
|
+
this.jsonRpcError(res, null, -32700, "parse error: invalid JSON");
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (typeof message !== "object" || message === null || message.method === void 0) {
|
|
590
|
+
this.jsonRpcError(res, message?.id ?? null, -32600, "invalid request");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const id = message.id ?? null;
|
|
594
|
+
const sessionId = this.registry.sessionIdFor(req);
|
|
595
|
+
if (id === null) {
|
|
596
|
+
res.writeHead(202, { ...corsHeaders(), "Content-Length": "0" }).end();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
const { result, extraHeaders } = await this.dispatch(message, sessionId);
|
|
601
|
+
res.writeHead(200, {
|
|
602
|
+
...corsHeaders(),
|
|
603
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
604
|
+
"Mcp-Protocol-Version": messageProtocolVersion(req),
|
|
605
|
+
...extraHeaders
|
|
606
|
+
});
|
|
607
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const code = error.code ?? -32603;
|
|
610
|
+
this.jsonRpcError(res, id, code, error.message, void 0, messageProtocolVersion(req));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async dispatch(message, sessionId) {
|
|
614
|
+
const method = message.method ?? "";
|
|
615
|
+
switch (method) {
|
|
616
|
+
case "initialize": {
|
|
617
|
+
const newSessionId = this.registry.ensureSession(sessionId);
|
|
618
|
+
const clientInfo = message.params?.clientInfo;
|
|
619
|
+
const clientName = typeof clientInfo?.name === "string" && clientInfo.name !== "" ? clientInfo.name : void 0;
|
|
620
|
+
if (clientName !== void 0) {
|
|
621
|
+
this.registry.noteClient(newSessionId, clientName);
|
|
622
|
+
}
|
|
623
|
+
this.onInitialize(newSessionId, clientName);
|
|
624
|
+
const requested = message.params?.protocolVersion;
|
|
625
|
+
const protocolVersion = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : LATEST_VERSION;
|
|
626
|
+
return {
|
|
627
|
+
extraHeaders: { "Mcp-Session-Id": newSessionId },
|
|
628
|
+
result: {
|
|
629
|
+
protocolVersion,
|
|
630
|
+
capabilities: { tools: { listChanged: false } },
|
|
631
|
+
serverInfo: { name: this.info.name, version: this.info.version },
|
|
632
|
+
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."
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
case "tools/list":
|
|
637
|
+
return { result: { tools: this.tools.map((tool) => ({
|
|
638
|
+
name: tool.name,
|
|
639
|
+
description: tool.description,
|
|
640
|
+
inputSchema: tool.inputSchema
|
|
641
|
+
})) } };
|
|
642
|
+
case "tools/call": {
|
|
643
|
+
const params = message.params ?? {};
|
|
644
|
+
if (typeof params.name !== "string") throw rpcError(-32602, "tools/call requires a string name");
|
|
645
|
+
const tool = this.tools.find((candidate) => candidate.name === params.name);
|
|
646
|
+
if (!tool) throw rpcError(-32602, `unknown tool: ${params.name}`);
|
|
647
|
+
const args = params.arguments ?? {};
|
|
648
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
649
|
+
throw rpcError(-32602, "tools/call arguments must be an object");
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const value = await tool.handler(args, sessionId);
|
|
653
|
+
return { result: { content: [{ type: "text", text: JSON.stringify(value) }], isError: false } };
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return {
|
|
656
|
+
result: {
|
|
657
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
|
|
658
|
+
isError: true
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
case "ping":
|
|
664
|
+
return { result: {} };
|
|
665
|
+
default:
|
|
666
|
+
throw rpcError(-32601, `method not found: ${method}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
jsonRpcError(res, id, code, message, data, protocolVersion) {
|
|
670
|
+
res.writeHead(code === -32700 || code === -32600 ? 400 : 200, {
|
|
671
|
+
...corsHeaders(),
|
|
672
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
673
|
+
...protocolVersion ? { "Mcp-Protocol-Version": protocolVersion } : {}
|
|
674
|
+
});
|
|
675
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message, ...data !== void 0 ? { data } : {} } }));
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
function rpcError(code, message) {
|
|
679
|
+
const error = new Error(message);
|
|
680
|
+
error.code = code;
|
|
681
|
+
return error;
|
|
682
|
+
}
|
|
683
|
+
function messageProtocolVersion(req) {
|
|
684
|
+
return req.headers["mcp-protocol-version"] ?? LATEST_VERSION;
|
|
685
|
+
}
|
|
686
|
+
function corsHeaders() {
|
|
687
|
+
return {
|
|
688
|
+
"Access-Control-Allow-Origin": "*",
|
|
689
|
+
"Access-Control-Allow-Headers": "*",
|
|
690
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
function readBody(req) {
|
|
694
|
+
return new Promise((resolve, reject) => {
|
|
695
|
+
const chunks = [];
|
|
696
|
+
let size = 0;
|
|
697
|
+
req.on("data", (chunk) => {
|
|
698
|
+
size += chunk.length;
|
|
699
|
+
if (size > MAX_BODY_BYTES) {
|
|
700
|
+
reject(new Error("request body too large"));
|
|
701
|
+
req.destroy();
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
chunks.push(chunk);
|
|
705
|
+
});
|
|
706
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
707
|
+
req.on("error", reject);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/index.ts
|
|
712
|
+
var SERVER_NAME = "agent-comm-hub";
|
|
713
|
+
var SERVER_VERSION = "0.1.6";
|
|
714
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
715
|
+
var DEFAULT_PORT = 18764;
|
|
716
|
+
var DEFAULT_PATH = "/mcp";
|
|
717
|
+
var DEFAULT_CONFIG = {
|
|
718
|
+
host: DEFAULT_HOST,
|
|
719
|
+
port: DEFAULT_PORT,
|
|
720
|
+
path: DEFAULT_PATH,
|
|
721
|
+
maxQueue: 200,
|
|
722
|
+
historyLimit: 100,
|
|
723
|
+
waitTimeoutMs: 6e4,
|
|
724
|
+
defaultWaitMs: 3e4,
|
|
725
|
+
connectedWindowMs: 3e4,
|
|
726
|
+
peerIdleTimeoutMs: 6e5
|
|
727
|
+
};
|
|
728
|
+
function startHub(config = {}, log2 = console) {
|
|
729
|
+
const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
|
|
730
|
+
const resolved = { ...DEFAULT_CONFIG, ...overrides };
|
|
731
|
+
const hub = new AgentHub({
|
|
732
|
+
maxQueue: resolved.maxQueue,
|
|
733
|
+
historyLimit: resolved.historyLimit,
|
|
734
|
+
waitTimeoutMs: resolved.waitTimeoutMs,
|
|
735
|
+
connectedWindowMs: resolved.connectedWindowMs,
|
|
736
|
+
peerIdleTimeoutMs: resolved.peerIdleTimeoutMs,
|
|
737
|
+
onPeerGc: (peerId) => registry.unbindPeerId(peerId),
|
|
738
|
+
// The idle GC must never evict a peer whose session has a live SSE channel.
|
|
739
|
+
isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
|
|
740
|
+
});
|
|
741
|
+
const registry = new SessionRegistry();
|
|
742
|
+
const mcp = new McpStreamableHttpServer(
|
|
743
|
+
hubTools(hub, registry, { defaultWaitMs: resolved.defaultWaitMs, waitTimeoutMs: resolved.waitTimeoutMs }),
|
|
744
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
745
|
+
registry,
|
|
746
|
+
(message) => log2.warn(message),
|
|
747
|
+
(sessionId, clientName) => {
|
|
748
|
+
try {
|
|
749
|
+
const peer = autoRegisterPeer(hub, registry, sessionId, clientName);
|
|
750
|
+
if (peer !== void 0) log2.info(`peer joined: ${peer}`);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
log2.warn(`auto-register failed: ${error.message}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
);
|
|
756
|
+
const server = createServer();
|
|
757
|
+
mcp.attach(server, resolved.path);
|
|
758
|
+
server.on("error", (error) => log2.warn(`hub http server error: ${error.message}`));
|
|
759
|
+
server.listen(resolved.port, resolved.host, () => {
|
|
760
|
+
log2.info(`agent-comm-hub listening on http://${resolved.host}:${resolved.port}${resolved.path}`);
|
|
761
|
+
});
|
|
762
|
+
return {
|
|
763
|
+
hub,
|
|
764
|
+
registry,
|
|
765
|
+
server,
|
|
766
|
+
mcp,
|
|
767
|
+
close: () => {
|
|
768
|
+
hub.dispose();
|
|
769
|
+
mcp.close();
|
|
770
|
+
server.closeAllConnections?.();
|
|
771
|
+
server.close();
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// src/setup.ts
|
|
777
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
778
|
+
import { existsSync } from "node:fs";
|
|
779
|
+
import { homedir } from "node:os";
|
|
780
|
+
import { dirname, join } from "node:path";
|
|
781
|
+
import { fileURLToPath } from "node:url";
|
|
782
|
+
var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
|
|
783
|
+
var DEFAULT_SERVER = "agent-hub";
|
|
784
|
+
function defaultSkillSrc() {
|
|
785
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "SKILL.md");
|
|
786
|
+
}
|
|
787
|
+
function stamp() {
|
|
788
|
+
const now = /* @__PURE__ */ new Date();
|
|
789
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
790
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
791
|
+
}
|
|
792
|
+
async function readJson(file) {
|
|
793
|
+
if (!existsSync(file)) return null;
|
|
794
|
+
try {
|
|
795
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
796
|
+
} catch (error) {
|
|
797
|
+
throw new Error(`cannot parse JSON ${file}: ${error.message}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async function writeJsonNoBom(file, doc) {
|
|
801
|
+
await mkdir(dirname(file), { recursive: true });
|
|
802
|
+
await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
803
|
+
}
|
|
804
|
+
async function backup(file) {
|
|
805
|
+
const bak = `${file}.bak-${stamp()}`;
|
|
806
|
+
await copyFile(file, bak);
|
|
807
|
+
return bak;
|
|
808
|
+
}
|
|
809
|
+
async function mergeJsonServer(file, section, entry, opts) {
|
|
810
|
+
if (!existsSync(file)) return "skipped";
|
|
811
|
+
const doc = await readJson(file);
|
|
812
|
+
if (doc === null) return "skipped";
|
|
813
|
+
let servers = doc[section];
|
|
814
|
+
if (servers === void 0 || typeof servers !== "object" || Array.isArray(servers)) {
|
|
815
|
+
servers = {};
|
|
816
|
+
doc[section] = servers;
|
|
817
|
+
}
|
|
818
|
+
const has = Object.prototype.hasOwnProperty.call(servers, opts.serverName);
|
|
819
|
+
if (opts.remove) {
|
|
820
|
+
if (!has) return "absent";
|
|
821
|
+
delete servers[opts.serverName];
|
|
822
|
+
await backup(file);
|
|
823
|
+
await writeJsonNoBom(file, doc);
|
|
824
|
+
return "removed";
|
|
825
|
+
}
|
|
826
|
+
if (has) {
|
|
827
|
+
const existing = servers[opts.serverName];
|
|
828
|
+
if (existing?.url === opts.url) return "unchanged";
|
|
829
|
+
}
|
|
830
|
+
servers[opts.serverName] = entry;
|
|
831
|
+
await backup(file);
|
|
832
|
+
await writeJsonNoBom(file, doc);
|
|
833
|
+
return "changed";
|
|
834
|
+
}
|
|
835
|
+
async function mergeTomlSection(file, opts) {
|
|
836
|
+
if (!existsSync(file)) return "skipped";
|
|
837
|
+
const text = await readFile(file, "utf8");
|
|
838
|
+
const marker = `[mcp_servers.${opts.serverName}]`;
|
|
839
|
+
const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
|
|
840
|
+
if (opts.remove) {
|
|
841
|
+
if (!markerRe.test(text)) return "absent";
|
|
842
|
+
const cleaned = text.replace(new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\][^\\r\\n]*(\\r?\\n(?!\\[).*)*(\\r?\\n)?`, "m"), "");
|
|
843
|
+
await backup(file);
|
|
844
|
+
await writeFile(file, cleaned, "utf8");
|
|
845
|
+
return "removed";
|
|
846
|
+
}
|
|
847
|
+
if (markerRe.test(text)) return "unchanged";
|
|
848
|
+
const block = `
|
|
849
|
+
${marker}
|
|
850
|
+
type = "streamable-http"
|
|
851
|
+
url = "${opts.url}"
|
|
852
|
+
`;
|
|
853
|
+
await backup(file);
|
|
854
|
+
await writeFile(file, text.trimEnd() + block, "utf8");
|
|
855
|
+
return "changed";
|
|
856
|
+
}
|
|
857
|
+
function escapeRegExp(value) {
|
|
858
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
859
|
+
}
|
|
860
|
+
async function syncSkill(skillDir, skillSrc, remove, log2) {
|
|
861
|
+
if (remove) {
|
|
862
|
+
if (existsSync(skillDir)) {
|
|
863
|
+
await mkdir(dirname(skillDir), { recursive: true });
|
|
864
|
+
await rmRecursive(skillDir);
|
|
865
|
+
log2(` skill removed: ${skillDir}`);
|
|
866
|
+
}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (!existsSync(skillSrc)) {
|
|
870
|
+
log2(` SKILL.md source missing: ${skillSrc} (skipped)`);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
await mkdir(skillDir, { recursive: true });
|
|
874
|
+
await copyFile(skillSrc, join(skillDir, "SKILL.md"));
|
|
875
|
+
log2(` skill -> ${join(skillDir, "SKILL.md")}`);
|
|
876
|
+
}
|
|
877
|
+
async function rmRecursive(dir) {
|
|
878
|
+
const { rm } = await import("node:fs/promises");
|
|
879
|
+
await rm(dir, { recursive: true, force: true });
|
|
880
|
+
}
|
|
881
|
+
async function runSetup(options = {}) {
|
|
882
|
+
const url = options.url ?? DEFAULT_URL;
|
|
883
|
+
const serverName = options.serverName ?? DEFAULT_SERVER;
|
|
884
|
+
const home = options.homeDir ?? homedir();
|
|
885
|
+
const skillSrc = options.skillSrc ?? defaultSkillSrc();
|
|
886
|
+
const remove = options.remove === true;
|
|
887
|
+
const log2 = options.log ?? ((message) => console.log(message));
|
|
888
|
+
const summary = { done: [], unchanged: [], skipped: [], errors: [] };
|
|
889
|
+
const record = (status, label, file) => {
|
|
890
|
+
if (status === "changed" || status === "removed") summary.done.push(`${label}: ${file}`);
|
|
891
|
+
else if (status === "unchanged" || status === "absent") summary.unchanged.push(`${label}: ${file}`);
|
|
892
|
+
else if (status === "skipped") summary.skipped.push(`${label}: ${file}`);
|
|
893
|
+
};
|
|
894
|
+
const jsonTargets = [
|
|
895
|
+
{ label: "mcode", file: join(home, ".minimax", "mcp.json"), section: "mcpServers", entry: { url, type: "streamable-http", enabled: true, configured: true, timeout: 12e4, description: "agent-comm-hub: talk to every other agent connected to the hub." } },
|
|
896
|
+
{ label: "mcode", file: join(home, ".minimax", "mcp", "mcp.json"), section: "mcpServers", entry: { url, type: "streamable-http", enabled: true, configured: true, timeout: 12e4, description: "agent-comm-hub: talk to every other agent connected to the hub." } },
|
|
897
|
+
{ label: "opencode", file: join(home, ".config", "opencode", "opencode.json"), section: "mcp", entry: { type: "remote", url, enabled: true } },
|
|
898
|
+
{ label: "kimi-code", file: join(home, ".kimi-code", "mcp.json"), section: "mcpServers", entry: { transport: "http", url, startupTimeoutMs: 3e4, toolTimeoutMs: 12e4 } },
|
|
899
|
+
{ label: "gemini-cli", file: join(home, ".gemini", "settings.json"), section: "mcpServers", entry: { type: "http", url } }
|
|
900
|
+
];
|
|
901
|
+
for (const target of jsonTargets) {
|
|
902
|
+
try {
|
|
903
|
+
const status = await mergeJsonServer(target.file, target.section, target.entry, { serverName, url, remove });
|
|
904
|
+
record(status, target.label, target.file);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
summary.errors.push(`${target.label}: ${target.file} \u2014 ${error.message}`);
|
|
907
|
+
log2(` ${target.label}: SKIPPED \u2014 ${error.message}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const codexFile = join(home, ".codex", "config.toml");
|
|
911
|
+
try {
|
|
912
|
+
const status = await mergeTomlSection(codexFile, { serverName, url, remove });
|
|
913
|
+
record(status, "codex", codexFile);
|
|
914
|
+
} catch (error) {
|
|
915
|
+
summary.errors.push(`codex: ${codexFile} \u2014 ${error.message}`);
|
|
916
|
+
log2(` codex: SKIPPED \u2014 ${error.message}`);
|
|
917
|
+
}
|
|
918
|
+
const skillDirs = [
|
|
919
|
+
join(home, ".agents", "skills", serverName),
|
|
920
|
+
// cross-agent standard
|
|
921
|
+
join(home, ".minimax", "skills", serverName),
|
|
922
|
+
join(home, ".config", "opencode", "skills", serverName),
|
|
923
|
+
join(home, ".kimi-code", "skills", serverName),
|
|
924
|
+
join(home, ".gemini", "skills", serverName),
|
|
925
|
+
join(home, ".codex", "skills", serverName),
|
|
926
|
+
join(home, ".claude", "skills", serverName)
|
|
927
|
+
// config is manual; skill still useful
|
|
928
|
+
];
|
|
929
|
+
for (const dir of skillDirs) {
|
|
930
|
+
try {
|
|
931
|
+
await syncSkill(dir, skillSrc, remove, log2);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
summary.errors.push(`skill ${dir} \u2014 ${error.message}`);
|
|
934
|
+
log2(` skill ${dir}: SKIPPED \u2014 ${error.message}`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (remove) log2("done. Manual targets (see agents/README.md): Claude Code (.mcp.json), DSH (cordis.patch.yml).");
|
|
938
|
+
else log2("done. Manual targets (see agents/README.md): Claude Code (.mcp.json), DSH (cordis.patch.yml). Restart agent sessions to pick up the MCP server.");
|
|
939
|
+
return summary;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/ops.ts
|
|
943
|
+
import { execFileSync } from "node:child_process";
|
|
944
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
945
|
+
import { homedir as homedir2 } from "node:os";
|
|
946
|
+
import { join as join2 } from "node:path";
|
|
947
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
948
|
+
async function runStatus(options = {}) {
|
|
949
|
+
const host = options.host ?? "127.0.0.1";
|
|
950
|
+
const port = options.port ?? 18764;
|
|
951
|
+
const path = options.path ?? "/mcp";
|
|
952
|
+
const url = options.url ?? `http://${host}:${port}${path}`;
|
|
953
|
+
const probeName = "agent-comm-hub-cli";
|
|
954
|
+
const notRunning = { running: false, url, peers: [] };
|
|
955
|
+
try {
|
|
956
|
+
const headers = {};
|
|
957
|
+
let id = 0;
|
|
958
|
+
const rpc = async (method, params) => {
|
|
959
|
+
const res = await fetch(url, {
|
|
960
|
+
method: "POST",
|
|
961
|
+
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...headers },
|
|
962
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, ...params !== void 0 ? { params } : {} })
|
|
963
|
+
});
|
|
964
|
+
const session = res.headers.get("mcp-session-id");
|
|
965
|
+
if (session) headers["Mcp-Session-Id"] = session;
|
|
966
|
+
const json = await res.json();
|
|
967
|
+
return json;
|
|
968
|
+
};
|
|
969
|
+
const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: probeName, version: "cli" } });
|
|
970
|
+
if (init.error) return { ...notRunning, error: init.error.message };
|
|
971
|
+
const version = init.result?.serverInfo?.version ?? void 0;
|
|
972
|
+
const call = await rpc("tools/call", { name: "bridge_peers", arguments: {} });
|
|
973
|
+
const text = call.result?.content?.[0]?.text;
|
|
974
|
+
const parsed = text ? JSON.parse(text) : { peers: [] };
|
|
975
|
+
const peers = (parsed.peers ?? []).filter((peer) => peer.id !== probeName);
|
|
976
|
+
await rpc("tools/call", { name: "bridge_unregister", arguments: {} }).catch(() => void 0);
|
|
977
|
+
return { running: true, url, version, peers };
|
|
978
|
+
} catch (error) {
|
|
979
|
+
return { ...notRunning, error: error.message };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function cliPath() {
|
|
983
|
+
return fileURLToPath2(import.meta.url);
|
|
984
|
+
}
|
|
985
|
+
function nodeExe() {
|
|
986
|
+
return process.execPath;
|
|
987
|
+
}
|
|
988
|
+
function run(command, args, dryRun) {
|
|
989
|
+
if (dryRun) return `[dry-run] ${command} ${args.join(" ")}`;
|
|
990
|
+
return execFileSync(command, args, { encoding: "utf8", windowsHide: true }).trim();
|
|
991
|
+
}
|
|
992
|
+
function runService(options) {
|
|
993
|
+
const messages = [];
|
|
994
|
+
const port = options.port ?? 18764;
|
|
995
|
+
const host = options.host ?? "127.0.0.1";
|
|
996
|
+
const path = options.path ?? "/mcp";
|
|
997
|
+
const dryRun = options.dryRun === true;
|
|
998
|
+
try {
|
|
999
|
+
if (process.platform === "win32") {
|
|
1000
|
+
const appData = process.env.APPDATA ?? join2(homedir2(), "AppData", "Roaming");
|
|
1001
|
+
const launcherDir = join2(appData, "agent-comm-hub");
|
|
1002
|
+
const vbs = join2(launcherDir, "agent-comm-hub.vbs");
|
|
1003
|
+
const runKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
1004
|
+
const valueName = "agent-comm-hub";
|
|
1005
|
+
if (options.action === "install") {
|
|
1006
|
+
const cmd = `"${nodeExe()}" "${cliPath()}" --host ${host} --port ${port} --path ${path}`;
|
|
1007
|
+
const vbsContent = `CreateObject("WScript.Shell").Run "${cmd.replace(/"/g, '""')}", 0, False
|
|
1008
|
+
`;
|
|
1009
|
+
if (dryRun) {
|
|
1010
|
+
messages.push(`[dry-run] write ${vbs}`);
|
|
1011
|
+
messages.push(`[dry-run] reg add "${runKey}" /v ${valueName} /t REG_SZ /d "wscript.exe \\"${vbs}\\"" /f`);
|
|
1012
|
+
} else {
|
|
1013
|
+
mkdirSync(launcherDir, { recursive: true });
|
|
1014
|
+
writeFileSync(vbs, vbsContent);
|
|
1015
|
+
execFileSync("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
|
|
1016
|
+
messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
|
|
1017
|
+
messages.push(`start it now with: wscript.exe "${vbs}"`);
|
|
1018
|
+
}
|
|
1019
|
+
} else {
|
|
1020
|
+
if (dryRun) {
|
|
1021
|
+
messages.push(`[dry-run] reg delete "${runKey}" /v ${valueName} /f`);
|
|
1022
|
+
messages.push(`[dry-run] del ${vbs}`);
|
|
1023
|
+
} else {
|
|
1024
|
+
try {
|
|
1025
|
+
execFileSync("reg", ["delete", runKey, "/v", valueName, "/f"], { encoding: "utf8", windowsHide: true });
|
|
1026
|
+
} catch {
|
|
1027
|
+
}
|
|
1028
|
+
rmSync(launcherDir, { recursive: true, force: true });
|
|
1029
|
+
messages.push("auto-start removed (Run key + hidden launcher)");
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return { ok: true, messages };
|
|
1033
|
+
}
|
|
1034
|
+
if (process.platform === "linux") {
|
|
1035
|
+
const unitDir = join2(homedir2(), ".config", "systemd", "user");
|
|
1036
|
+
const unitFile = join2(unitDir, "agent-comm-hub.service");
|
|
1037
|
+
if (options.action === "install") {
|
|
1038
|
+
const unit = `[Unit]
|
|
1039
|
+
Description=agent-comm-hub (multi-peer MCP hub)
|
|
1040
|
+
After=network.target
|
|
1041
|
+
|
|
1042
|
+
[Service]
|
|
1043
|
+
ExecStart=${nodeExe()} ${cliPath()} --host ${host} --port ${port} --path ${path}
|
|
1044
|
+
Restart=on-failure
|
|
1045
|
+
|
|
1046
|
+
[Install]
|
|
1047
|
+
WantedBy=default.target
|
|
1048
|
+
`;
|
|
1049
|
+
if (dryRun) {
|
|
1050
|
+
messages.push(`[dry-run] would write ${unitFile}`);
|
|
1051
|
+
messages.push(`[dry-run] systemctl --user daemon-reload && systemctl --user enable --now agent-comm-hub`);
|
|
1052
|
+
} else {
|
|
1053
|
+
mkdirSync(unitDir, { recursive: true });
|
|
1054
|
+
writeFileSync(unitFile, unit);
|
|
1055
|
+
run("systemctl", ["--user", "daemon-reload"], false);
|
|
1056
|
+
const out = run("systemctl", ["--user", "enable", "--now", "agent-comm-hub"], false);
|
|
1057
|
+
messages.push(out || `systemd user unit installed and enabled: ${unitFile}`);
|
|
1058
|
+
}
|
|
1059
|
+
} else {
|
|
1060
|
+
if (dryRun) {
|
|
1061
|
+
messages.push(`[dry-run] systemctl --user disable --now agent-comm-hub && rm ${unitFile}`);
|
|
1062
|
+
} else {
|
|
1063
|
+
run("systemctl", ["--user", "disable", "--now", "agent-comm-hub"], false);
|
|
1064
|
+
rmSync(unitFile, { force: true });
|
|
1065
|
+
run("systemctl", ["--user", "daemon-reload"], false);
|
|
1066
|
+
messages.push(`systemd user unit removed: ${unitFile}`);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return { ok: true, messages };
|
|
1070
|
+
}
|
|
1071
|
+
return { ok: false, messages: [`auto-start is not implemented for ${process.platform} \u2014 use pm2 or your platform's supervisor`] };
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
return { ok: false, messages: [`${error.message}`] };
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// src/cli.ts
|
|
1078
|
+
function parseArgs(argv) {
|
|
1079
|
+
const args = {};
|
|
1080
|
+
const numeric = /* @__PURE__ */ new Set(["--port", "--max-queue", "--history-limit", "--wait-timeout-ms", "--default-wait-ms", "--connected-window-ms", "--peer-idle-timeout-ms"]);
|
|
1081
|
+
const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name"]);
|
|
1082
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1083
|
+
const flag = argv[i];
|
|
1084
|
+
if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
|
|
1085
|
+
args[flag] = true;
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (flag === "--remove" || flag === "--dry-run") {
|
|
1089
|
+
args[flag] = true;
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
const value = argv[i + 1];
|
|
1093
|
+
if (numeric.has(flag)) {
|
|
1094
|
+
const parsed = Number(value);
|
|
1095
|
+
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${flag} expects a positive number, got '${value}'`);
|
|
1096
|
+
args[flag] = parsed;
|
|
1097
|
+
i++;
|
|
1098
|
+
} else if (string.has(flag)) {
|
|
1099
|
+
if (value === void 0) throw new Error(`${flag} expects a value`);
|
|
1100
|
+
args[flag] = value;
|
|
1101
|
+
i++;
|
|
1102
|
+
} else {
|
|
1103
|
+
throw new Error(`unknown flag: ${flag}`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return args;
|
|
1107
|
+
}
|
|
1108
|
+
function printHelp() {
|
|
1109
|
+
console.log(`agent-comm-hub v${SERVER_VERSION} \u2014 generic multi-peer MCP hub
|
|
1110
|
+
|
|
1111
|
+
Usage:
|
|
1112
|
+
agent-comm-hub [options] start the hub
|
|
1113
|
+
agent-comm-hub setup [options] sync the MCP entry + skill into
|
|
1114
|
+
every installed agent (incremental,
|
|
1115
|
+
idempotent; --remove undoes)
|
|
1116
|
+
agent-comm-hub status [options] show hub health + online peers
|
|
1117
|
+
agent-comm-hub service install|uninstall [options]
|
|
1118
|
+
one-shot auto-start (Windows
|
|
1119
|
+
Task Scheduler / Linux systemd;
|
|
1120
|
+
--dry-run prints commands)
|
|
1121
|
+
|
|
1122
|
+
Hub options:
|
|
1123
|
+
--host <addr> Bind address (default 127.0.0.1)
|
|
1124
|
+
--port <n> Listen port (default 18764)
|
|
1125
|
+
--path <p> MCP endpoint path (default /mcp)
|
|
1126
|
+
--max-queue <n> Queued messages per peer before dropping oldest (default 200)
|
|
1127
|
+
--history-limit <n> Retained history messages (default 100)
|
|
1128
|
+
--wait-timeout-ms <n> Long-poll ceiling for bridge_wait (default 60000)
|
|
1129
|
+
--default-wait-ms <n> bridge_wait default budget (default 30000)
|
|
1130
|
+
--connected-window-ms <n> Peer counts as active within this window (default 30000)
|
|
1131
|
+
--peer-idle-timeout-ms <n> Auto-unregister idle peers after this; 0 disables (default 600000)
|
|
1132
|
+
|
|
1133
|
+
Setup options:
|
|
1134
|
+
--url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
|
|
1135
|
+
--server-name <name> Config key (default agent-hub)
|
|
1136
|
+
--remove Uninstall instead of install
|
|
1137
|
+
|
|
1138
|
+
-h, --help Show this help
|
|
1139
|
+
-V, --version Show version
|
|
1140
|
+
|
|
1141
|
+
Agents connect via MCP streamable-http at http://<host>:<port><path> and are
|
|
1142
|
+
auto-registered at connect (client name becomes the peer id).`);
|
|
1143
|
+
}
|
|
1144
|
+
var log = {
|
|
1145
|
+
info: (message) => console.log(message),
|
|
1146
|
+
warn: (message) => console.warn(message)
|
|
1147
|
+
};
|
|
1148
|
+
try {
|
|
1149
|
+
const argv = process.argv.slice(2);
|
|
1150
|
+
const [command, ...rest] = argv;
|
|
1151
|
+
if (command === "setup" || command === "install") {
|
|
1152
|
+
const args2 = parseArgs(rest);
|
|
1153
|
+
if (args2["--help"] || args2["-h"]) {
|
|
1154
|
+
printHelp();
|
|
1155
|
+
process.exit(0);
|
|
1156
|
+
}
|
|
1157
|
+
await runSetup({
|
|
1158
|
+
url: args2["--url"],
|
|
1159
|
+
serverName: args2["--server-name"],
|
|
1160
|
+
remove: args2["--remove"] === true,
|
|
1161
|
+
log: (message) => log.info(message)
|
|
1162
|
+
});
|
|
1163
|
+
process.exit(0);
|
|
1164
|
+
}
|
|
1165
|
+
if (command === "status") {
|
|
1166
|
+
const args2 = parseArgs(rest);
|
|
1167
|
+
const result = await runStatus({
|
|
1168
|
+
host: args2["--host"],
|
|
1169
|
+
port: args2["--port"],
|
|
1170
|
+
path: args2["--path"],
|
|
1171
|
+
url: args2["--url"]
|
|
1172
|
+
});
|
|
1173
|
+
if (!result.running) {
|
|
1174
|
+
console.error(`hub is not running at ${result.url}${result.error ? ` (${result.error})` : ""}`);
|
|
1175
|
+
console.error("start it with: agent-comm-hub");
|
|
1176
|
+
process.exit(1);
|
|
1177
|
+
}
|
|
1178
|
+
console.log(`agent-comm-hub${result.version ? ` v${result.version}` : ""} at ${result.url}`);
|
|
1179
|
+
if (result.peers.length === 0) {
|
|
1180
|
+
console.log("no peers online yet \u2014 start an agent session to see it appear");
|
|
1181
|
+
} else {
|
|
1182
|
+
for (const peer of result.peers) {
|
|
1183
|
+
console.log(` ${peer.id.padEnd(32)} ${peer.connected ? "connected" : "offline"}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
process.exit(0);
|
|
1187
|
+
}
|
|
1188
|
+
if (command === "service") {
|
|
1189
|
+
const [action, ...serviceRest] = rest;
|
|
1190
|
+
if (action !== "install" && action !== "uninstall") {
|
|
1191
|
+
console.error(`service: expected 'install' or 'uninstall', got '${action ?? ""}'`);
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
}
|
|
1194
|
+
const args2 = parseArgs(serviceRest);
|
|
1195
|
+
const result = runService({
|
|
1196
|
+
action,
|
|
1197
|
+
host: args2["--host"],
|
|
1198
|
+
port: args2["--port"],
|
|
1199
|
+
path: args2["--path"],
|
|
1200
|
+
dryRun: args2["--dry-run"] === true
|
|
1201
|
+
});
|
|
1202
|
+
for (const message of result.messages) log.info(message);
|
|
1203
|
+
if (!result.ok) {
|
|
1204
|
+
console.error("service: failed \u2014 see messages above");
|
|
1205
|
+
process.exit(1);
|
|
1206
|
+
}
|
|
1207
|
+
process.exit(0);
|
|
1208
|
+
}
|
|
1209
|
+
const args = parseArgs(argv);
|
|
1210
|
+
if (args["--help"] || args["-h"]) {
|
|
1211
|
+
printHelp();
|
|
1212
|
+
process.exit(0);
|
|
1213
|
+
}
|
|
1214
|
+
if (args["--version"] || args["-V"]) {
|
|
1215
|
+
console.log(SERVER_VERSION);
|
|
1216
|
+
process.exit(0);
|
|
1217
|
+
}
|
|
1218
|
+
const hub = startHub({
|
|
1219
|
+
host: args["--host"],
|
|
1220
|
+
port: args["--port"],
|
|
1221
|
+
path: args["--path"],
|
|
1222
|
+
maxQueue: args["--max-queue"],
|
|
1223
|
+
historyLimit: args["--history-limit"],
|
|
1224
|
+
waitTimeoutMs: args["--wait-timeout-ms"],
|
|
1225
|
+
defaultWaitMs: args["--default-wait-ms"],
|
|
1226
|
+
connectedWindowMs: args["--connected-window-ms"],
|
|
1227
|
+
peerIdleTimeoutMs: args["--peer-idle-timeout-ms"]
|
|
1228
|
+
}, log);
|
|
1229
|
+
const shutdown = () => {
|
|
1230
|
+
log.info("agent-comm-hub shutting down");
|
|
1231
|
+
hub.close();
|
|
1232
|
+
process.exit(0);
|
|
1233
|
+
};
|
|
1234
|
+
process.on("SIGINT", shutdown);
|
|
1235
|
+
process.on("SIGTERM", shutdown);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
console.error(`agent-comm-hub: ${error.message}`);
|
|
1238
|
+
process.exit(1);
|
|
1239
|
+
}
|