agent-comm-hub 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +415 -361
- package/README.zh.md +289 -250
- package/agents/registry.json +201 -0
- package/lib/cli.js +1044 -83
- package/lib/index.js +642 -4
- package/lib/setup.js +284 -50
- package/package.json +3 -3
package/lib/cli.js
CHANGED
|
@@ -3,6 +3,417 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
|
|
6
|
+
// src/herdr-ctl.ts
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import net from "node:net";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
var AGENT_STATUSES = ["idle", "working", "blocked", "done", "unknown"];
|
|
12
|
+
var HerdrCtl = class {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
this.bin = options.bin ?? "herdr";
|
|
16
|
+
this.baseArgs = options.baseArgs ?? [];
|
|
17
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
|
|
18
|
+
this.socketPath = options.socketPath ?? defaultSocketPath();
|
|
19
|
+
this.sendRequest = options.sendRequest ?? ((method, params) => socketSend(this.socketPath, method, params));
|
|
20
|
+
}
|
|
21
|
+
bin;
|
|
22
|
+
baseArgs;
|
|
23
|
+
defaultTimeoutMs;
|
|
24
|
+
sendRequest;
|
|
25
|
+
socketPath;
|
|
26
|
+
// ---- pane-level control (socket API; works for ANY pane, no agent
|
|
27
|
+
// detection required — this is the channel that drives agents herdr does
|
|
28
|
+
// not recognize, e.g. MiniMax Code) --------------------------------
|
|
29
|
+
/** `pane.list` — every pane in the session (ids, titles, agent states). */
|
|
30
|
+
async paneList() {
|
|
31
|
+
const result = await this.sendRequest("pane.list", {});
|
|
32
|
+
const raw = result.panes;
|
|
33
|
+
if (!Array.isArray(raw)) throw new Error(`herdr pane.list: unexpected result shape: ${JSON.stringify(result)}`);
|
|
34
|
+
return raw.map((item) => toPane(item));
|
|
35
|
+
}
|
|
36
|
+
/** `pane.send_input` — type text into a pane's input (physical input; the
|
|
37
|
+
* pane's program receives it as keystrokes). */
|
|
38
|
+
async paneSendText(paneId, text) {
|
|
39
|
+
if (text === "") throw new Error("paneSendText: text must not be empty");
|
|
40
|
+
await this.sendRequest("pane.send_input", { pane_id: paneId, text });
|
|
41
|
+
}
|
|
42
|
+
/** `pane.send_keys` — raw key presses (Enter, esc, ctrl-c, ...). */
|
|
43
|
+
async paneSendKeys(paneId, keys) {
|
|
44
|
+
if (keys.length === 0) throw new Error("paneSendKeys: at least one key is required");
|
|
45
|
+
await this.sendRequest("pane.send_keys", { pane_id: paneId, keys });
|
|
46
|
+
}
|
|
47
|
+
/** `pane.read` — recent terminal output of a pane. */
|
|
48
|
+
async paneRead(paneId, options = {}) {
|
|
49
|
+
const params = { pane_id: paneId, source: options.source ?? "recent", format: "text", strip_ansi: true };
|
|
50
|
+
if (options.lines !== void 0) params.lines = options.lines;
|
|
51
|
+
const result = await this.sendRequest("pane.read", params);
|
|
52
|
+
const raw = result.read ?? result;
|
|
53
|
+
const read = raw;
|
|
54
|
+
return {
|
|
55
|
+
paneId: String(read.pane_id ?? paneId),
|
|
56
|
+
tabId: read.tab_id === void 0 ? "" : String(read.tab_id),
|
|
57
|
+
workspaceId: read.workspace_id === void 0 ? null : String(read.workspace_id),
|
|
58
|
+
source: String(read.source ?? options.source ?? "recent"),
|
|
59
|
+
text: String(read.text ?? ""),
|
|
60
|
+
revision: Number(read.revision ?? 0),
|
|
61
|
+
truncated: read.truncated === true
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** `pane.wait_for_output` — block until the pane output matches a pattern
|
|
65
|
+
* (substring or regex), or the budget elapses. Returns the matched pane
|
|
66
|
+
* output, or null on timeout. */
|
|
67
|
+
async paneWaitForOutput(paneId, match, options = {}) {
|
|
68
|
+
const params = {
|
|
69
|
+
pane_id: paneId,
|
|
70
|
+
source: options.source ?? "recent",
|
|
71
|
+
match: { type: match.type, value: match.value },
|
|
72
|
+
strip_ansi: true
|
|
73
|
+
};
|
|
74
|
+
if (options.lines !== void 0) params.lines = options.lines;
|
|
75
|
+
if (options.timeoutMs !== void 0) params.timeout_ms = options.timeoutMs;
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result = await this.sendRequest("pane.wait_for_output", params);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (/(timeout|timed out)/i.test(error.message)) return null;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const raw = result.read ?? result;
|
|
84
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
85
|
+
const read = raw;
|
|
86
|
+
return {
|
|
87
|
+
paneId: String(read.pane_id ?? paneId),
|
|
88
|
+
tabId: read.tab_id === void 0 ? "" : String(read.tab_id),
|
|
89
|
+
workspaceId: read.workspace_id === void 0 ? null : String(read.workspace_id),
|
|
90
|
+
source: String(read.source ?? options.source ?? "recent"),
|
|
91
|
+
text: String(read.text ?? ""),
|
|
92
|
+
revision: Number(read.revision ?? 0),
|
|
93
|
+
truncated: read.truncated === true
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// ---- smart control: use herdr's agent features when the target is
|
|
97
|
+
// recognized, transparently fall back to pane-level control otherwise ---
|
|
98
|
+
/** Detect whether herdr recognizes `target` as an agent (i.e. its
|
|
99
|
+
* agent.get succeeds). Unrecognized panes fall back to pane control. */
|
|
100
|
+
async detectTarget(target) {
|
|
101
|
+
try {
|
|
102
|
+
await this.get(target);
|
|
103
|
+
return "agent";
|
|
104
|
+
} catch {
|
|
105
|
+
return "pane";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Prompt with automatic fallback: agent.prompt for recognized agents
|
|
109
|
+
* (state-machine wait), pane.send_input + output-settling wait otherwise. */
|
|
110
|
+
async promptSmart(target, text, options = {}) {
|
|
111
|
+
if (await this.detectTarget(target) === "agent") {
|
|
112
|
+
const settled2 = await this.prompt(target, text, {
|
|
113
|
+
wait: options.wait,
|
|
114
|
+
until: options.until,
|
|
115
|
+
timeoutMs: options.timeoutMs
|
|
116
|
+
});
|
|
117
|
+
return { via: "agent", settled: settled2 };
|
|
118
|
+
}
|
|
119
|
+
await this.paneSendText(target, text);
|
|
120
|
+
if (options.enter !== false) await this.paneSendKeys(target, ["Enter"]);
|
|
121
|
+
if (options.wait !== true) return { via: "pane", settled: null };
|
|
122
|
+
const budget = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
123
|
+
const settled = await this.waitForPaneSettled(target, budget);
|
|
124
|
+
return { via: "pane", settled };
|
|
125
|
+
}
|
|
126
|
+
/** Wait with automatic fallback: agent.wait (state machine) for recognized
|
|
127
|
+
* agents; pane output-settling heuristic otherwise. */
|
|
128
|
+
async waitSmart(target, options = {}) {
|
|
129
|
+
if (await this.detectTarget(target) === "agent") {
|
|
130
|
+
const settled2 = await this.wait(target, options);
|
|
131
|
+
return { via: "agent", settled: settled2 };
|
|
132
|
+
}
|
|
133
|
+
const budget = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
134
|
+
const settled = await this.waitForPaneSettled(target, budget);
|
|
135
|
+
return { via: "pane", settled };
|
|
136
|
+
}
|
|
137
|
+
/** Read terminal output of a target. Uses the socket `pane.read` for both
|
|
138
|
+
* recognized agents and plain panes: the CLI's `agent read --format text`
|
|
139
|
+
* prints RAW text to stdout (no JSON envelope), so it cannot feed the
|
|
140
|
+
* structured result path — the socket request is uniform and carries
|
|
141
|
+
* revision/truncated metadata. */
|
|
142
|
+
async readSmart(target, options = {}) {
|
|
143
|
+
return this.paneRead(target, options);
|
|
144
|
+
}
|
|
145
|
+
/** Key presses with automatic fallback: agent.send_keys for recognized
|
|
146
|
+
* agents, pane.send_keys otherwise. */
|
|
147
|
+
async keysSmart(target, keys) {
|
|
148
|
+
if (keys.length === 0) throw new Error("keysSmart: at least one key is required");
|
|
149
|
+
if (await this.detectTarget(target) === "agent") {
|
|
150
|
+
await this.sendKeys(target, keys);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await this.paneSendKeys(target, keys);
|
|
154
|
+
}
|
|
155
|
+
/** Status with automatic fallback: agent.get for recognized agents; a
|
|
156
|
+
* pane-derived summary (status unknown) otherwise. */
|
|
157
|
+
async statusSmart(target) {
|
|
158
|
+
try {
|
|
159
|
+
const agent = await this.get(target);
|
|
160
|
+
return { agent, pane: null };
|
|
161
|
+
} catch {
|
|
162
|
+
try {
|
|
163
|
+
const panes = await this.paneList();
|
|
164
|
+
const pane = panes.find((p) => p.paneId === target);
|
|
165
|
+
return { agent: null, pane: pane ?? null };
|
|
166
|
+
} catch {
|
|
167
|
+
return { agent: null, pane: null };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Heuristic settle for unrecognized panes: poll pane.read revisions until
|
|
172
|
+
* the output stops changing for a short window, or the budget elapses. */
|
|
173
|
+
async waitForPaneSettled(paneId, timeoutMs) {
|
|
174
|
+
const startedAt = Date.now();
|
|
175
|
+
let lastRevision = -1;
|
|
176
|
+
let stableTicks = 0;
|
|
177
|
+
const stableTarget = 2;
|
|
178
|
+
const pollMs = 1500;
|
|
179
|
+
for (; ; ) {
|
|
180
|
+
const elapsed = Date.now() - startedAt;
|
|
181
|
+
if (elapsed >= timeoutMs) return null;
|
|
182
|
+
try {
|
|
183
|
+
const read = await this.paneRead(paneId, { source: "recent" });
|
|
184
|
+
if (read.revision === lastRevision) {
|
|
185
|
+
stableTicks++;
|
|
186
|
+
if (stableTicks >= stableTarget) {
|
|
187
|
+
return { paneId, status: "idle", waitedMs: Date.now() - startedAt };
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
stableTicks = 0;
|
|
191
|
+
lastRevision = read.revision;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
}
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ---- agent-level control (CLI; requires herdr to recognize the agent) ---
|
|
199
|
+
/** `herdr agent list` — every agent pane herdr currently detects. */
|
|
200
|
+
async list() {
|
|
201
|
+
const result = await this.run(["agent", "list"]);
|
|
202
|
+
const raw = result.agents;
|
|
203
|
+
if (!Array.isArray(raw)) throw new Error(`herdr agent list: unexpected result shape: ${JSON.stringify(result)}`);
|
|
204
|
+
return raw.map((item) => toAgent(item));
|
|
205
|
+
}
|
|
206
|
+
/** `herdr agent get <target>` — one agent pane (by paneId or name). */
|
|
207
|
+
async get(target) {
|
|
208
|
+
const result = await this.run(["agent", "get", target]);
|
|
209
|
+
const raw = result.agent;
|
|
210
|
+
if (raw === void 0 || raw === null) throw new Error(`herdr agent get: unexpected result shape: ${JSON.stringify(result)}`);
|
|
211
|
+
return toAgent(raw);
|
|
212
|
+
}
|
|
213
|
+
/** `herdr agent read <target>` — recent terminal output of the pane. */
|
|
214
|
+
async read(target, options = {}) {
|
|
215
|
+
const args = ["agent", "read", target];
|
|
216
|
+
if (options.source !== void 0) args.push("--source", options.source);
|
|
217
|
+
if (options.lines !== void 0) args.push("--lines", String(options.lines));
|
|
218
|
+
args.push("--format", "text");
|
|
219
|
+
const result = await this.run(args);
|
|
220
|
+
const raw = result.read ?? result;
|
|
221
|
+
const read = raw;
|
|
222
|
+
return {
|
|
223
|
+
paneId: String(read.pane_id ?? target),
|
|
224
|
+
tabId: read.tab_id === void 0 ? "" : String(read.tab_id),
|
|
225
|
+
workspaceId: read.workspace_id === void 0 ? null : String(read.workspace_id),
|
|
226
|
+
source: String(read.source ?? options.source ?? "recent"),
|
|
227
|
+
text: String(read.text ?? ""),
|
|
228
|
+
revision: Number(read.revision ?? 0),
|
|
229
|
+
truncated: read.truncated === true
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** `herdr agent send-keys <target> <KEY>...` — raw key presses (Enter,
|
|
233
|
+
* esc, ctrl-c, arrows; named keys are passed through to herdr). */
|
|
234
|
+
async sendKeys(target, keys) {
|
|
235
|
+
if (keys.length === 0) throw new Error("sendKeys: at least one key is required");
|
|
236
|
+
await this.run(["agent", "send-keys", target, ...keys]);
|
|
237
|
+
}
|
|
238
|
+
/** `herdr agent prompt <target> <text>` — submit text to the agent's
|
|
239
|
+
* input line (a slash command like `/compact` is executed by the agent's
|
|
240
|
+
* TUI, not treated as chat content). With `wait`, blocks until the agent
|
|
241
|
+
* settles (default idle/done/blocked, or the exact `until` states) or
|
|
242
|
+
* `timeoutMs` elapses. */
|
|
243
|
+
async prompt(target, text, options = {}) {
|
|
244
|
+
const args = ["agent", "prompt", target, text];
|
|
245
|
+
if (options.wait === true) args.push("--wait");
|
|
246
|
+
for (const status of options.until ?? []) args.push("--until", status);
|
|
247
|
+
if (options.timeoutMs !== void 0) args.push("--timeout", String(options.timeoutMs));
|
|
248
|
+
const result = await this.run(args, options.timeoutMs);
|
|
249
|
+
return settleFrom(result, target);
|
|
250
|
+
}
|
|
251
|
+
/** `herdr agent wait <target>` — block until the agent reaches one of the
|
|
252
|
+
* requested states (default idle/done/blocked) or `timeoutMs` elapses. */
|
|
253
|
+
async wait(target, options = {}) {
|
|
254
|
+
const args = ["agent", "wait", target];
|
|
255
|
+
for (const status of options.until ?? []) args.push("--until", status);
|
|
256
|
+
if (options.timeoutMs !== void 0) args.push("--timeout", String(options.timeoutMs));
|
|
257
|
+
const result = await this.run(args, options.timeoutMs);
|
|
258
|
+
return settleFrom(result, target);
|
|
259
|
+
}
|
|
260
|
+
/** Run one herdr CLI invocation and parse its JSON envelope. */
|
|
261
|
+
run(args, timeoutMs) {
|
|
262
|
+
return new Promise((resolve, reject) => {
|
|
263
|
+
execFile(
|
|
264
|
+
this.bin,
|
|
265
|
+
[...this.baseArgs, ...args],
|
|
266
|
+
{ timeout: timeoutMs ?? this.defaultTimeoutMs, windowsHide: true, maxBuffer: 16 * 1024 * 1024 },
|
|
267
|
+
(error, stdout, stderr) => {
|
|
268
|
+
if (error) {
|
|
269
|
+
const err = error;
|
|
270
|
+
if (err.code === "ENOENT") {
|
|
271
|
+
reject(new Error(`herdr CLI not found ('${this.bin}') \u2014 install herdr (https://herdr.dev) or point --herdr-bin at it`));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const payload = stdout || stderr || "";
|
|
275
|
+
let envelope = null;
|
|
276
|
+
try {
|
|
277
|
+
envelope = JSON.parse(payload);
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
if (envelope !== null && typeof envelope === "object" && envelope.error !== void 0) {
|
|
281
|
+
reject(new Error(`${envelope.error.code ?? "herdr error"}: ${envelope.error.message ?? "unknown error"}`));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const hint = payload.trim() !== "" ? ` \u2014 ${payload.trim().slice(0, 200)}` : "";
|
|
285
|
+
reject(new Error(`herdr ${args.slice(0, 2).join(" ")} failed${hint}`));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
let parsed = null;
|
|
289
|
+
try {
|
|
290
|
+
parsed = JSON.parse(stdout);
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
294
|
+
reject(new Error(`herdr returned non-JSON output: ${stdout.slice(0, 200)}`));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (parsed.error !== void 0) {
|
|
298
|
+
reject(new Error(`${parsed.error.code ?? "herdr error"}: ${parsed.error.message ?? "unknown error"}`));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
resolve(parsed.result);
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
function toAgent(raw) {
|
|
308
|
+
const status = raw.agent_status;
|
|
309
|
+
return {
|
|
310
|
+
paneId: String(raw.pane_id ?? ""),
|
|
311
|
+
tabId: raw.tab_id === void 0 ? "" : String(raw.tab_id),
|
|
312
|
+
terminalId: raw.terminal_id === void 0 ? "" : String(raw.terminal_id),
|
|
313
|
+
name: raw.name === void 0 ? null : String(raw.name),
|
|
314
|
+
agent: raw.agent === void 0 ? null : String(raw.agent),
|
|
315
|
+
displayAgent: raw.display_agent === void 0 ? null : String(raw.display_agent),
|
|
316
|
+
status: AGENT_STATUSES.includes(status) ? status : "unknown",
|
|
317
|
+
cwd: raw.cwd === void 0 ? null : String(raw.cwd),
|
|
318
|
+
focused: raw.focused === true,
|
|
319
|
+
interactiveReady: raw.interactive_ready === true,
|
|
320
|
+
launchPending: raw.launch_pending === true,
|
|
321
|
+
terminalTitle: raw.terminal_title === void 0 ? null : String(raw.terminal_title),
|
|
322
|
+
revision: Number(raw.revision ?? 0)
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function settleFrom(result, target) {
|
|
326
|
+
if (result === null || typeof result !== "object") return null;
|
|
327
|
+
const obj = result;
|
|
328
|
+
const event = obj.event;
|
|
329
|
+
const status = event?.agent_status ?? obj.agent_status ?? obj.status;
|
|
330
|
+
if (AGENT_STATUSES.includes(status)) {
|
|
331
|
+
return {
|
|
332
|
+
paneId: String(event?.pane_id ?? obj.pane_id ?? target),
|
|
333
|
+
status,
|
|
334
|
+
waitedMs: Number(obj.waited_ms ?? null) || null
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
function toPane(raw) {
|
|
340
|
+
const status = raw.agent_status;
|
|
341
|
+
return {
|
|
342
|
+
paneId: String(raw.pane_id ?? ""),
|
|
343
|
+
tabId: raw.tab_id === void 0 ? "" : String(raw.tab_id),
|
|
344
|
+
workspaceId: raw.workspace_id === void 0 ? "" : String(raw.workspace_id),
|
|
345
|
+
terminalId: raw.terminal_id === void 0 ? "" : String(raw.terminal_id),
|
|
346
|
+
title: raw.terminal_title_stripped === void 0 ? null : String(raw.terminal_title_stripped),
|
|
347
|
+
agentStatus: AGENT_STATUSES.includes(status) ? status : "unknown",
|
|
348
|
+
cwd: raw.cwd === void 0 ? null : String(raw.cwd),
|
|
349
|
+
focused: raw.focused === true,
|
|
350
|
+
revision: Number(raw.revision ?? 0)
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function defaultSocketPath() {
|
|
354
|
+
if (process.platform === "win32") {
|
|
355
|
+
const base = process.env.APPDATA ?? process.env.USERPROFILE ?? ".";
|
|
356
|
+
return `\\\\.\\pipe\\${path.join(base, "herdr", "herdr.sock")}`;
|
|
357
|
+
}
|
|
358
|
+
return path.join(os.homedir(), ".config", "herdr", "herdr.sock");
|
|
359
|
+
}
|
|
360
|
+
function socketSend(socketPath, method, params) {
|
|
361
|
+
return new Promise((resolve, reject) => {
|
|
362
|
+
const socket = net.createConnection(socketPath);
|
|
363
|
+
let buffer = "";
|
|
364
|
+
let settled = false;
|
|
365
|
+
const fail = (error) => {
|
|
366
|
+
if (settled) return;
|
|
367
|
+
settled = true;
|
|
368
|
+
socket.destroy();
|
|
369
|
+
reject(error);
|
|
370
|
+
};
|
|
371
|
+
const timeout = setTimeout(() => fail(new Error(`herdr socket request timed out: ${method}`)), 15e3);
|
|
372
|
+
socket.on("connect", () => {
|
|
373
|
+
socket.write(JSON.stringify({ id: `dsh-${Date.now()}`, method, params }) + "\n");
|
|
374
|
+
});
|
|
375
|
+
socket.on("data", (chunk) => {
|
|
376
|
+
buffer += chunk.toString("utf8");
|
|
377
|
+
const lines = buffer.split("\n");
|
|
378
|
+
buffer = lines.pop() ?? "";
|
|
379
|
+
for (const line of lines) {
|
|
380
|
+
if (line.trim() === "") continue;
|
|
381
|
+
let envelope;
|
|
382
|
+
try {
|
|
383
|
+
envelope = JSON.parse(line);
|
|
384
|
+
} catch {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (settled) continue;
|
|
388
|
+
settled = true;
|
|
389
|
+
clearTimeout(timeout);
|
|
390
|
+
socket.destroy();
|
|
391
|
+
if (envelope.error !== void 0) {
|
|
392
|
+
reject(new Error(`${envelope.error.code ?? "herdr error"}: ${envelope.error.message ?? "unknown error"}`));
|
|
393
|
+
} else {
|
|
394
|
+
resolve(envelope.result);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
socket.on("error", (error) => fail(error));
|
|
399
|
+
socket.on("close", () => {
|
|
400
|
+
if (!settled && buffer.trim() !== "") {
|
|
401
|
+
try {
|
|
402
|
+
const envelope = JSON.parse(buffer);
|
|
403
|
+
settled = true;
|
|
404
|
+
clearTimeout(timeout);
|
|
405
|
+
if (envelope.error !== void 0) reject(new Error(`${envelope.error.code ?? "herdr error"}: ${envelope.error.message ?? "unknown error"}`));
|
|
406
|
+
else resolve(envelope.result);
|
|
407
|
+
} catch {
|
|
408
|
+
fail(new Error(`herdr socket closed without a response: ${method}`));
|
|
409
|
+
}
|
|
410
|
+
} else if (!settled) {
|
|
411
|
+
fail(new Error(`herdr socket closed without a response: ${method}`));
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
6
417
|
// src/hub.ts
|
|
7
418
|
import { randomUUID } from "node:crypto";
|
|
8
419
|
|
|
@@ -291,6 +702,22 @@ function hubTools(hub, registry, options) {
|
|
|
291
702
|
ts: message.ts
|
|
292
703
|
});
|
|
293
704
|
const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
|
|
705
|
+
const checkControl = (peer) => {
|
|
706
|
+
const herdr = options.herdr;
|
|
707
|
+
if (herdr === void 0) {
|
|
708
|
+
throw new Error("herdr control not enabled \u2014 start the hub with --herdr-bin or pass a herdrCtl to hubTools");
|
|
709
|
+
}
|
|
710
|
+
const control = options.herdrControlPeers ?? "all";
|
|
711
|
+
if (control !== "all" && !control.has(peer)) {
|
|
712
|
+
throw new Error(`peer '${peer}' is not allowed to use bridge_agent_* tools`);
|
|
713
|
+
}
|
|
714
|
+
return herdr;
|
|
715
|
+
};
|
|
716
|
+
const asStatuses = (value) => {
|
|
717
|
+
if (!Array.isArray(value)) return void 0;
|
|
718
|
+
const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
|
|
719
|
+
return statuses.length > 0 ? statuses : void 0;
|
|
720
|
+
};
|
|
294
721
|
const wrap = (peerAware, handler) => async (args, sessionId) => {
|
|
295
722
|
const peer = peerAware ? requirePeer(sessionId) : "";
|
|
296
723
|
return handler(args, peer, sessionId);
|
|
@@ -416,6 +843,203 @@ function hubTools(hub, registry, options) {
|
|
|
416
843
|
description: "Recent messages involving you (newest first); pass `peer` to inspect another peer's conversation. Use to refresh context after a reconnect.",
|
|
417
844
|
inputSchema: schema({ peer: optStr("PeerId whose conversation to inspect; default: yourself."), limit: int("How many messages to return (default 20).") }),
|
|
418
845
|
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) }))
|
|
846
|
+
},
|
|
847
|
+
// ---- herdr control tools ------------------------------------------
|
|
848
|
+
// These type into real agent terminals via the herdr runtime. They are
|
|
849
|
+
// gated by checkControl and documented as physical input: unlike
|
|
850
|
+
// bridge_chat (a mailbox message the model may ignore), a prompt here is
|
|
851
|
+
// executed by the target's TUI — slash commands included.
|
|
852
|
+
{
|
|
853
|
+
name: "bridge_agent_list",
|
|
854
|
+
description: "List agent panes detected by the herdr terminal runtime (paneId, agent kind, lifecycle status, cwd, interactive-ready). Use a paneId as the `target` of the other bridge_agent_* tools. Control tools: they type into the target terminal \u2014 use with care.",
|
|
855
|
+
inputSchema: schema({}),
|
|
856
|
+
handler: wrap(true, async (_args, peer) => ({ agents: await checkControl(peer).list() }))
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
name: "bridge_agent_status",
|
|
860
|
+
description: 'Live status of a target: for agents herdr recognizes, the full lifecycle state (idle/working/blocked/done) and agent kind; for unrecognized panes, a pane-derived summary with status "unknown" and `pane` populated. `target` is a herdr paneId from bridge_pane_list / bridge_agent_list.',
|
|
861
|
+
inputSchema: schema({ target: str("herdr paneId, e.g. w1:p1, from bridge_agent_list or bridge_pane_list.") }, ["target"]),
|
|
862
|
+
handler: wrap(true, async (args, peer) => {
|
|
863
|
+
const result = await checkControl(peer).statusSmart(String(args.target));
|
|
864
|
+
return { agent: result.agent, ...result.pane !== null ? { pane: result.pane } : {} };
|
|
865
|
+
})
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
name: "bridge_agent_prompt",
|
|
869
|
+
description: 'Submit text directly into the target terminal. For agents herdr recognizes, uses agent.prompt \u2014 a state-machine wait (`wait: true` blocks until idle/done/blocked, exact states via `until`). For unrecognized panes (e.g. MiniMax Code), falls back to pane-level input (`pane.send_input`) and waits for the pane output to settle. Slash commands are executed by the target\'s TUI either way. Returns `via: "agent" | "pane"` so callers know which channel was used.',
|
|
870
|
+
inputSchema: schema(
|
|
871
|
+
{
|
|
872
|
+
target: str("herdr paneId, e.g. w1:p1, from bridge_pane_list / bridge_agent_list."),
|
|
873
|
+
text: str("Text to submit (slash commands are executed, not sent as chat)."),
|
|
874
|
+
wait: { type: "boolean", description: "Wait for the agent to settle after submission (default false)." },
|
|
875
|
+
until: { type: "array", items: { type: "string", enum: [...AGENT_STATUSES] }, description: "Exact states to wait for (agent channel; default: idle/done/blocked)." },
|
|
876
|
+
timeoutMs: int("Wait cap in ms (default 30000).")
|
|
877
|
+
},
|
|
878
|
+
["target", "text"]
|
|
879
|
+
),
|
|
880
|
+
handler: wrap(true, async (args, peer) => {
|
|
881
|
+
const ctl = checkControl(peer);
|
|
882
|
+
const waiting = args.wait === true;
|
|
883
|
+
const result = await ctl.promptSmart(String(args.target), String(args.text), {
|
|
884
|
+
wait: waiting,
|
|
885
|
+
until: asStatuses(args.until),
|
|
886
|
+
timeoutMs: args.timeoutMs === void 0 ? void 0 : Number(args.timeoutMs)
|
|
887
|
+
});
|
|
888
|
+
return waiting ? { submitted: true, via: result.via, settled: result.settled } : { submitted: true, via: result.via };
|
|
889
|
+
})
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
name: "bridge_agent_wait",
|
|
893
|
+
description: 'Wait until the target settles. For agents herdr recognizes: state-machine wait (agent.wait \u2014 idle/working/blocked/done, `until` for exact states). For unrecognized panes: falls back to polling pane output until it stops changing. Returns `via: "agent" | "pane"`; a `settled: null` result means the timeout fired first.',
|
|
894
|
+
inputSchema: schema(
|
|
895
|
+
{
|
|
896
|
+
target: str("herdr paneId, e.g. w1:p1, from bridge_pane_list / bridge_agent_list."),
|
|
897
|
+
until: { type: "array", items: { type: "string", enum: [...AGENT_STATUSES] }, description: "Exact states to wait for (agent channel; default: idle/done/blocked)." },
|
|
898
|
+
timeoutMs: int("Wait cap in ms (default 30000).")
|
|
899
|
+
},
|
|
900
|
+
["target"]
|
|
901
|
+
),
|
|
902
|
+
handler: wrap(true, async (args, peer) => {
|
|
903
|
+
const result = await checkControl(peer).waitSmart(String(args.target), {
|
|
904
|
+
until: asStatuses(args.until),
|
|
905
|
+
timeoutMs: args.timeoutMs === void 0 ? void 0 : Number(args.timeoutMs)
|
|
906
|
+
});
|
|
907
|
+
return { via: result.via, settled: result.settled };
|
|
908
|
+
})
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
name: "bridge_agent_read",
|
|
912
|
+
description: "Read the target's recent terminal output (plain text). Uses agent.read for recognized agents, pane.read otherwise. Use to collect the reply of an agent that is not connected to the hub (its output never enters a mailbox).",
|
|
913
|
+
inputSchema: schema(
|
|
914
|
+
{
|
|
915
|
+
target: str("herdr paneId, e.g. w1:p1, from bridge_pane_list / bridge_agent_list."),
|
|
916
|
+
lines: int("How many lines to read (default: all recent)."),
|
|
917
|
+
source: { type: "string", enum: ["visible", "recent", "recent-unwrapped", "detection"], description: "Terminal snapshot source (default recent)." }
|
|
918
|
+
},
|
|
919
|
+
["target"]
|
|
920
|
+
),
|
|
921
|
+
handler: wrap(
|
|
922
|
+
true,
|
|
923
|
+
async (args, peer) => checkControl(peer).readSmart(String(args.target), {
|
|
924
|
+
lines: args.lines === void 0 ? void 0 : Number(args.lines),
|
|
925
|
+
source: args.source === void 0 ? void 0 : args.source
|
|
926
|
+
})
|
|
927
|
+
)
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
name: "bridge_agent_keys",
|
|
931
|
+
description: "Send raw key presses to the target terminal \u2014 Enter, esc, ctrl-c, arrows, etc. Use to dismiss permission prompts or interrupt a stuck agent. Keys are passed verbatim.",
|
|
932
|
+
inputSchema: schema(
|
|
933
|
+
{
|
|
934
|
+
target: str("herdr paneId, e.g. w1:p1, from bridge_pane_list / bridge_agent_list."),
|
|
935
|
+
keys: { type: "array", items: { type: "string" }, description: 'Keys to send, e.g. ["Enter"], ["esc"], ["ctrl-c", "Enter"].' }
|
|
936
|
+
},
|
|
937
|
+
["target", "keys"]
|
|
938
|
+
),
|
|
939
|
+
handler: wrap(true, async (args, peer) => {
|
|
940
|
+
const ctl = checkControl(peer);
|
|
941
|
+
const keys = Array.isArray(args.keys) ? args.keys.map(String) : [];
|
|
942
|
+
if (keys.length === 0) throw new Error("keys: at least one key is required");
|
|
943
|
+
await ctl.keysSmart(String(args.target), keys);
|
|
944
|
+
return { ok: true, sent: keys };
|
|
945
|
+
})
|
|
946
|
+
},
|
|
947
|
+
// ---- pane-level control tools (socket API) -----------------------
|
|
948
|
+
// Unlike the bridge_agent_* tools (which require herdr to RECOGNIZE the
|
|
949
|
+
// agent), these drive any pane through the herdr local socket: physical
|
|
950
|
+
// input and output for agents herdr does not know (e.g. MiniMax Code).
|
|
951
|
+
{
|
|
952
|
+
name: "bridge_pane_list",
|
|
953
|
+
description: "List every herdr pane (ids, titles, agent status, cwd) via the herdr socket \u2014 including panes running agents herdr does not recognize. Use a paneId as `target` of bridge_pane_send / bridge_pane_keys / bridge_pane_read. Control tools: they type into real terminals \u2014 use with care.",
|
|
954
|
+
inputSchema: schema({}),
|
|
955
|
+
handler: wrap(true, async (_args, peer) => ({ panes: await checkControl(peer).paneList() }))
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: "bridge_pane_send",
|
|
959
|
+
description: "Type text into a herdr pane's input line (physical keystrokes via the herdr socket; works for ANY pane, no agent detection needed). This is how you drive an agent herdr does not recognize: the text lands in the target's terminal as if typed. Slash commands are executed by the target's TUI. With enter: true (default), the text is submitted with Enter.",
|
|
960
|
+
inputSchema: schema(
|
|
961
|
+
{
|
|
962
|
+
target: str("herdr paneId, e.g. wT:p2, from bridge_pane_list."),
|
|
963
|
+
text: str("Text to type into the pane (slash commands are executed, not sent as chat)."),
|
|
964
|
+
enter: { type: "boolean", description: "Submit with Enter after typing (default true)." }
|
|
965
|
+
},
|
|
966
|
+
["target", "text"]
|
|
967
|
+
),
|
|
968
|
+
handler: wrap(true, async (args, peer) => {
|
|
969
|
+
const ctl = checkControl(peer);
|
|
970
|
+
const paneId = String(args.target);
|
|
971
|
+
const text = String(args.text);
|
|
972
|
+
await ctl.paneSendText(paneId, text);
|
|
973
|
+
if (args.enter !== false) await ctl.paneSendKeys(paneId, ["Enter"]);
|
|
974
|
+
return { ok: true, target: paneId, sent: text };
|
|
975
|
+
})
|
|
976
|
+
},
|
|
977
|
+
{
|
|
978
|
+
name: "bridge_pane_keys",
|
|
979
|
+
description: "Send raw key presses to any herdr pane (Enter, esc, ctrl-c, arrows...). Use to dismiss permission prompts or interrupt a stuck program in a pane herdr does not recognize as an agent.",
|
|
980
|
+
inputSchema: schema(
|
|
981
|
+
{
|
|
982
|
+
target: str("herdr paneId, e.g. wT:p2, from bridge_pane_list."),
|
|
983
|
+
keys: { type: "array", items: { type: "string" }, description: 'Keys to send, e.g. ["Enter"], ["esc"], ["ctrl-c"].' }
|
|
984
|
+
},
|
|
985
|
+
["target", "keys"]
|
|
986
|
+
),
|
|
987
|
+
handler: wrap(true, async (args, peer) => {
|
|
988
|
+
const ctl = checkControl(peer);
|
|
989
|
+
const keys = Array.isArray(args.keys) ? args.keys.map(String) : [];
|
|
990
|
+
if (keys.length === 0) throw new Error("keys: at least one key is required");
|
|
991
|
+
await ctl.paneSendKeys(String(args.target), keys);
|
|
992
|
+
return { ok: true, sent: keys };
|
|
993
|
+
})
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
name: "bridge_pane_read",
|
|
997
|
+
description: "Read a herdr pane's recent terminal output (plain text, ANSI stripped). Use to collect the reply of an agent that is not connected to the hub or not recognized by herdr.",
|
|
998
|
+
inputSchema: schema(
|
|
999
|
+
{
|
|
1000
|
+
target: str("herdr paneId, e.g. wT:p2, from bridge_pane_list."),
|
|
1001
|
+
lines: int("How many lines to read (default: all recent)."),
|
|
1002
|
+
source: { type: "string", enum: ["visible", "recent", "recent-unwrapped", "detection"], description: "Terminal snapshot source (default recent)." }
|
|
1003
|
+
},
|
|
1004
|
+
["target"]
|
|
1005
|
+
),
|
|
1006
|
+
handler: wrap(
|
|
1007
|
+
true,
|
|
1008
|
+
async (args, peer) => checkControl(peer).paneRead(String(args.target), {
|
|
1009
|
+
lines: args.lines === void 0 ? void 0 : Number(args.lines),
|
|
1010
|
+
source: args.source === void 0 ? void 0 : args.source
|
|
1011
|
+
})
|
|
1012
|
+
)
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
name: "bridge_pane_wait",
|
|
1016
|
+
description: "Wait until a herdr pane's output matches a pattern (substring or regex) or the budget elapses \u2014 the pane-level counterpart of bridge_agent_wait for agents herdr does not recognize. Returns the matched pane output, or `matched: null` on timeout.",
|
|
1017
|
+
inputSchema: schema(
|
|
1018
|
+
{
|
|
1019
|
+
target: str("herdr paneId, e.g. wT:p2, from bridge_pane_list."),
|
|
1020
|
+
match: {
|
|
1021
|
+
type: "object",
|
|
1022
|
+
properties: {
|
|
1023
|
+
type: { type: "string", enum: ["substring", "regex"], description: "Match kind (default substring)." },
|
|
1024
|
+
value: { type: "string", description: "Text or regex to match in the pane output." }
|
|
1025
|
+
},
|
|
1026
|
+
required: ["value"],
|
|
1027
|
+
description: "Output pattern to wait for."
|
|
1028
|
+
},
|
|
1029
|
+
timeoutMs: int("Wait cap in ms (default 30000).")
|
|
1030
|
+
},
|
|
1031
|
+
["target", "match"]
|
|
1032
|
+
),
|
|
1033
|
+
handler: wrap(true, async (args, peer) => {
|
|
1034
|
+
const ctl = checkControl(peer);
|
|
1035
|
+
const match = args.match ?? {};
|
|
1036
|
+
if (typeof match.value !== "string" || match.value === "") throw new Error("match.value: a non-empty string is required");
|
|
1037
|
+
const type = match.type === "regex" ? "regex" : "substring";
|
|
1038
|
+
const read = await ctl.paneWaitForOutput(String(args.target), { type, value: match.value }, {
|
|
1039
|
+
timeoutMs: args.timeoutMs === void 0 ? void 0 : Number(args.timeoutMs)
|
|
1040
|
+
});
|
|
1041
|
+
return read === null ? { matched: null } : { matched: read };
|
|
1042
|
+
})
|
|
419
1043
|
}
|
|
420
1044
|
];
|
|
421
1045
|
}
|
|
@@ -527,10 +1151,10 @@ var McpStreamableHttpServer = class {
|
|
|
527
1151
|
}
|
|
528
1152
|
sseStreams = /* @__PURE__ */ new Map();
|
|
529
1153
|
/** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
|
|
530
|
-
attach(server,
|
|
1154
|
+
attach(server, path2) {
|
|
531
1155
|
server.on("request", (req, res) => {
|
|
532
1156
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
533
|
-
if (url.pathname !==
|
|
1157
|
+
if (url.pathname !== path2) {
|
|
534
1158
|
res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
|
|
535
1159
|
return;
|
|
536
1160
|
}
|
|
@@ -710,7 +1334,7 @@ function readBody(req) {
|
|
|
710
1334
|
|
|
711
1335
|
// src/index.ts
|
|
712
1336
|
var SERVER_NAME = "agent-comm-hub";
|
|
713
|
-
var SERVER_VERSION = "0.
|
|
1337
|
+
var SERVER_VERSION = "0.4.0";
|
|
714
1338
|
var DEFAULT_HOST = "127.0.0.1";
|
|
715
1339
|
var DEFAULT_PORT = 18764;
|
|
716
1340
|
var DEFAULT_PATH = "/mcp";
|
|
@@ -739,8 +1363,20 @@ function startHub(config = {}, log2 = console) {
|
|
|
739
1363
|
isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
|
|
740
1364
|
});
|
|
741
1365
|
const registry = new SessionRegistry();
|
|
1366
|
+
const herdr = new HerdrCtl({
|
|
1367
|
+
bin: resolved.herdrBin,
|
|
1368
|
+
baseArgs: resolved.herdrBaseArgs,
|
|
1369
|
+
defaultTimeoutMs: resolved.herdrTimeoutMs,
|
|
1370
|
+
socketPath: resolved.herdrSocketPath,
|
|
1371
|
+
sendRequest: resolved.herdrSendRequest
|
|
1372
|
+
});
|
|
742
1373
|
const mcp = new McpStreamableHttpServer(
|
|
743
|
-
hubTools(hub, registry, {
|
|
1374
|
+
hubTools(hub, registry, {
|
|
1375
|
+
defaultWaitMs: resolved.defaultWaitMs,
|
|
1376
|
+
waitTimeoutMs: resolved.waitTimeoutMs,
|
|
1377
|
+
herdr,
|
|
1378
|
+
herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers)
|
|
1379
|
+
}),
|
|
744
1380
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
745
1381
|
registry,
|
|
746
1382
|
(message) => log2.warn(message),
|
|
@@ -775,14 +1411,189 @@ function startHub(config = {}, log2 = console) {
|
|
|
775
1411
|
|
|
776
1412
|
// src/setup.ts
|
|
777
1413
|
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
778
|
-
import { existsSync } from "node:fs";
|
|
1414
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1415
|
+
import { homedir as homedir2 } from "node:os";
|
|
1416
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
1417
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1418
|
+
|
|
1419
|
+
// src/discover.ts
|
|
1420
|
+
import { execFileSync } from "node:child_process";
|
|
1421
|
+
import { existsSync, readdirSync, accessSync, readFileSync, constants as fsConstants } from "node:fs";
|
|
779
1422
|
import { homedir } from "node:os";
|
|
780
|
-
import { dirname, join } from "node:path";
|
|
1423
|
+
import { dirname, join, sep } from "node:path";
|
|
781
1424
|
import { fileURLToPath } from "node:url";
|
|
1425
|
+
function registryFile() {
|
|
1426
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
|
|
1427
|
+
}
|
|
1428
|
+
function expandHome(file, home) {
|
|
1429
|
+
return file.startsWith("~/") ? join(home, file.slice(2)) : file;
|
|
1430
|
+
}
|
|
1431
|
+
function expandConfigFile(file, home) {
|
|
1432
|
+
const expanded = expandHome(file, home);
|
|
1433
|
+
const star = expanded.indexOf("*");
|
|
1434
|
+
if (star < 0) return [expanded];
|
|
1435
|
+
const prefix = expanded.slice(0, star);
|
|
1436
|
+
const suffix = expanded.slice(star + 1);
|
|
1437
|
+
const base = prefix.slice(0, prefix.lastIndexOf(sep));
|
|
1438
|
+
if (!existsSync(base)) return [];
|
|
1439
|
+
return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
|
|
1440
|
+
}
|
|
1441
|
+
function validateRegistry(registry) {
|
|
1442
|
+
if (!Array.isArray(registry.agents)) throw new Error('registry: missing "agents" array');
|
|
1443
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1444
|
+
for (const agent of registry.agents) {
|
|
1445
|
+
if (typeof agent.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) {
|
|
1446
|
+
throw new Error(`registry: bad agent id ${JSON.stringify(agent.id)}`);
|
|
1447
|
+
}
|
|
1448
|
+
if (ids.has(agent.id)) throw new Error(`registry: duplicate agent id '${agent.id}'`);
|
|
1449
|
+
ids.add(agent.id);
|
|
1450
|
+
if (!Array.isArray(agent.probe)) throw new Error(`registry: ${agent.id}: probe must be an array`);
|
|
1451
|
+
if (agent.npm !== void 0 && !Array.isArray(agent.npm)) throw new Error(`registry: ${agent.id}: npm must be an array`);
|
|
1452
|
+
if (!Array.isArray(agent.configs)) throw new Error(`registry: ${agent.id}: configs must be an array`);
|
|
1453
|
+
for (const config of agent.configs) {
|
|
1454
|
+
if (typeof config.file !== "string" || !config.file.startsWith("~/")) {
|
|
1455
|
+
throw new Error(`registry: ${agent.id}: config file must be '~'-relative`);
|
|
1456
|
+
}
|
|
1457
|
+
if (config.file.split("/").includes("..")) {
|
|
1458
|
+
throw new Error(`registry: ${agent.id}: config file must not contain '..'`);
|
|
1459
|
+
}
|
|
1460
|
+
if (config.file.split("*").length > 2) {
|
|
1461
|
+
throw new Error(`registry: ${agent.id}: at most one '*' segment allowed`);
|
|
1462
|
+
}
|
|
1463
|
+
if (!["json", "toml", "dsh"].includes(config.strategy)) {
|
|
1464
|
+
throw new Error(`registry: ${agent.id}: unknown strategy '${config.strategy}'`);
|
|
1465
|
+
}
|
|
1466
|
+
if (config.strategy === "json" && (typeof config.section !== "string" || config.entry === null)) {
|
|
1467
|
+
throw new Error(`registry: ${agent.id}: json strategy needs a section and an entry`);
|
|
1468
|
+
}
|
|
1469
|
+
if (config.strategy !== "json" && config.entry !== null) {
|
|
1470
|
+
throw new Error(`registry: ${agent.id}: only json strategy may carry an entry`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (agent.skill !== null && (typeof agent.skill !== "string" || !agent.skill.startsWith("~/"))) {
|
|
1474
|
+
throw new Error(`registry: ${agent.id}: skill must be '~'-relative or null`);
|
|
1475
|
+
}
|
|
1476
|
+
if (agent.os !== void 0 && (!Array.isArray(agent.os) || agent.os.some((os2) => !["win32", "darwin", "linux"].includes(os2)))) {
|
|
1477
|
+
throw new Error(`registry: ${agent.id}: invalid os list`);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
function loadRegistry(file = registryFile()) {
|
|
1482
|
+
const registry = JSON.parse(readFileSync(file, "utf8"));
|
|
1483
|
+
validateRegistry(registry);
|
|
1484
|
+
return registry;
|
|
1485
|
+
}
|
|
1486
|
+
function commandOnPath(command, pathEnv, pathext, platform) {
|
|
1487
|
+
const dirs = pathEnv.split(platform === "win32" ? ";" : ":");
|
|
1488
|
+
const extensions = platform === "win32" ? ["", ...(pathext || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
|
|
1489
|
+
for (const dir of dirs) {
|
|
1490
|
+
const base = dir === "" ? "." : dir;
|
|
1491
|
+
for (const ext of extensions) {
|
|
1492
|
+
const candidate = join(base, command + ext);
|
|
1493
|
+
try {
|
|
1494
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
1495
|
+
return true;
|
|
1496
|
+
} catch {
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return false;
|
|
1501
|
+
}
|
|
1502
|
+
function readNpmNames(root) {
|
|
1503
|
+
const names = [];
|
|
1504
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
1505
|
+
if (!entry.isDirectory()) continue;
|
|
1506
|
+
if (entry.name.startsWith("@")) {
|
|
1507
|
+
const scopeDir = join(root, entry.name);
|
|
1508
|
+
for (const sub of readdirSync(scopeDir, { withFileTypes: true })) {
|
|
1509
|
+
if (sub.isDirectory()) names.push(sub.name);
|
|
1510
|
+
}
|
|
1511
|
+
} else {
|
|
1512
|
+
names.push(entry.name);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return names;
|
|
1516
|
+
}
|
|
1517
|
+
function npmGlobalRoot(platform) {
|
|
1518
|
+
try {
|
|
1519
|
+
const out = execFileSync(platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"], { encoding: "utf8", windowsHide: true }).trim();
|
|
1520
|
+
return out || null;
|
|
1521
|
+
} catch {
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
function npmFallbackRoots(home, platform) {
|
|
1526
|
+
if (platform === "win32") {
|
|
1527
|
+
const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
1528
|
+
return [join(appData, "npm", "node_modules")];
|
|
1529
|
+
}
|
|
1530
|
+
return [
|
|
1531
|
+
"/usr/local/lib/node_modules",
|
|
1532
|
+
"/usr/lib/node_modules",
|
|
1533
|
+
...existsSync(join(home, ".nvm", "versions", "node")) ? readdirSync(join(home, ".nvm", "versions", "node")).map((dir) => join(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
|
|
1534
|
+
];
|
|
1535
|
+
}
|
|
1536
|
+
function discover(registry, options = {}) {
|
|
1537
|
+
const home = options.homeDir ?? homedir();
|
|
1538
|
+
const platform = options.platform ?? process.platform;
|
|
1539
|
+
const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
|
|
1540
|
+
const pathext = options.pathext ?? process.env.PATHEXT ?? "";
|
|
1541
|
+
let npmNames = null;
|
|
1542
|
+
if (!options.noNpm) {
|
|
1543
|
+
if (options.npmRoot !== void 0 && options.npmRoot !== null) {
|
|
1544
|
+
npmNames = existsSync(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
|
|
1545
|
+
} else if (options.npmRoot !== null) {
|
|
1546
|
+
const root = npmGlobalRoot(platform);
|
|
1547
|
+
if (root && existsSync(root)) {
|
|
1548
|
+
npmNames = readNpmNames(root);
|
|
1549
|
+
} else {
|
|
1550
|
+
npmNames = [];
|
|
1551
|
+
for (const fallback of npmFallbackRoots(home, platform)) {
|
|
1552
|
+
if (existsSync(fallback)) {
|
|
1553
|
+
npmNames = readNpmNames(fallback);
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
return registry.agents.filter((agent) => agent.os === void 0 || agent.os.includes(platform)).map((agent) => {
|
|
1561
|
+
let source = "none";
|
|
1562
|
+
const configFiles = [];
|
|
1563
|
+
for (const config of agent.configs) {
|
|
1564
|
+
for (const file of expandConfigFile(config.file, home)) {
|
|
1565
|
+
if (existsSync(file)) {
|
|
1566
|
+
configFiles.push(file);
|
|
1567
|
+
if (source === "none") source = "config";
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (source === "none" && agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
|
|
1572
|
+
source = "path";
|
|
1573
|
+
}
|
|
1574
|
+
if (source === "none" && npmNames !== null && (agent.probe.some((command) => npmNames.includes(command)) || (agent.npm ?? []).some((name) => npmNames.includes(name)))) {
|
|
1575
|
+
source = "npm";
|
|
1576
|
+
}
|
|
1577
|
+
return { id: agent.id, source, configFiles, present: source !== "none" };
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
function runDiscover(options = {}) {
|
|
1581
|
+
const log2 = options.log ?? ((message) => console.log(message));
|
|
1582
|
+
const found = discover(loadRegistry(), options);
|
|
1583
|
+
log2("discovered agents:");
|
|
1584
|
+
for (const agent of found) {
|
|
1585
|
+
const status = agent.present ? agent.source : "not installed";
|
|
1586
|
+
const files = agent.configFiles.length > 0 ? ` \u2014 ${agent.configFiles.join(", ")}` : "";
|
|
1587
|
+
log2(` ${agent.id.padEnd(16)} ${status}${files}`);
|
|
1588
|
+
}
|
|
1589
|
+
return found;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// src/setup.ts
|
|
782
1593
|
var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
|
|
783
1594
|
var DEFAULT_SERVER = "agent-hub";
|
|
784
1595
|
function defaultSkillSrc() {
|
|
785
|
-
return
|
|
1596
|
+
return join2(dirname2(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
|
|
786
1597
|
}
|
|
787
1598
|
function stamp() {
|
|
788
1599
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -790,7 +1601,7 @@ function stamp() {
|
|
|
790
1601
|
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
791
1602
|
}
|
|
792
1603
|
async function readJson(file) {
|
|
793
|
-
if (!
|
|
1604
|
+
if (!existsSync2(file)) return null;
|
|
794
1605
|
try {
|
|
795
1606
|
return JSON.parse(await readFile(file, "utf8"));
|
|
796
1607
|
} catch (error) {
|
|
@@ -798,7 +1609,7 @@ async function readJson(file) {
|
|
|
798
1609
|
}
|
|
799
1610
|
}
|
|
800
1611
|
async function writeJsonNoBom(file, doc) {
|
|
801
|
-
await mkdir(
|
|
1612
|
+
await mkdir(dirname2(file), { recursive: true });
|
|
802
1613
|
await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
803
1614
|
}
|
|
804
1615
|
async function backup(file) {
|
|
@@ -819,7 +1630,7 @@ function resolveSection(doc, section) {
|
|
|
819
1630
|
return node;
|
|
820
1631
|
}
|
|
821
1632
|
async function mergeJsonServer(file, section, entry, opts) {
|
|
822
|
-
if (!
|
|
1633
|
+
if (!existsSync2(file)) return "skipped";
|
|
823
1634
|
const doc = await readJson(file);
|
|
824
1635
|
if (doc === null) return "skipped";
|
|
825
1636
|
const servers = resolveSection(doc, section);
|
|
@@ -841,7 +1652,7 @@ async function mergeJsonServer(file, section, entry, opts) {
|
|
|
841
1652
|
return "changed";
|
|
842
1653
|
}
|
|
843
1654
|
async function mergeTomlSection(file, opts) {
|
|
844
|
-
if (!
|
|
1655
|
+
if (!existsSync2(file)) return "skipped";
|
|
845
1656
|
const text = await readFile(file, "utf8");
|
|
846
1657
|
const marker = `[mcp_servers.${opts.serverName}]`;
|
|
847
1658
|
const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
|
|
@@ -865,31 +1676,104 @@ url = "${opts.url}"
|
|
|
865
1676
|
function escapeRegExp(value) {
|
|
866
1677
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
867
1678
|
}
|
|
1679
|
+
var DSH_PATCH_MARKER = "# \u2500\u2500 agent-comm-hub MCP client";
|
|
1680
|
+
function dshPatchBlock(url, serverName) {
|
|
1681
|
+
return `
|
|
1682
|
+
${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --remove\`) \u2500
|
|
1683
|
+
- insert:
|
|
1684
|
+
- id: ${serverName}
|
|
1685
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
1686
|
+
config:
|
|
1687
|
+
serverName: ${serverName}
|
|
1688
|
+
transport: streamable-http
|
|
1689
|
+
url: ${url}
|
|
1690
|
+
`;
|
|
1691
|
+
}
|
|
1692
|
+
async function mergeDshPatch(file, opts) {
|
|
1693
|
+
if (!existsSync2(file)) return "skipped";
|
|
1694
|
+
const text = await readFile(file, "utf8");
|
|
1695
|
+
const lines = text.split("\n");
|
|
1696
|
+
const markerLine = lines.findIndex((line) => line.includes(DSH_PATCH_MARKER));
|
|
1697
|
+
const hasBlock = markerLine >= 0;
|
|
1698
|
+
const blockRange = () => {
|
|
1699
|
+
let entryStart = -1;
|
|
1700
|
+
for (let i = markerLine + 1; i < lines.length; i++) {
|
|
1701
|
+
if (/^- /.test(lines[i])) {
|
|
1702
|
+
entryStart = i;
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
let end = lines.length;
|
|
1707
|
+
if (entryStart >= 0) {
|
|
1708
|
+
for (let i = entryStart + 1; i < lines.length; i++) {
|
|
1709
|
+
if (/^- /.test(lines[i])) {
|
|
1710
|
+
end = i;
|
|
1711
|
+
break;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
let start = markerLine;
|
|
1716
|
+
while (start > 0 && lines[start - 1].trim() === "") start--;
|
|
1717
|
+
return { start, end };
|
|
1718
|
+
};
|
|
1719
|
+
const withoutBlock = () => {
|
|
1720
|
+
const { start, end } = blockRange();
|
|
1721
|
+
return lines.slice(0, start).concat(lines.slice(end)).join("\n");
|
|
1722
|
+
};
|
|
1723
|
+
if (opts.remove) {
|
|
1724
|
+
if (!hasBlock) return "absent";
|
|
1725
|
+
const cleaned = withoutBlock();
|
|
1726
|
+
await backup(file);
|
|
1727
|
+
await writeFile(file, cleaned, "utf8");
|
|
1728
|
+
return "removed";
|
|
1729
|
+
}
|
|
1730
|
+
if (hasBlock) {
|
|
1731
|
+
if (lines.some((line) => line.includes(`url: ${opts.url}`))) return "unchanged";
|
|
1732
|
+
const replaced = withoutBlock();
|
|
1733
|
+
await backup(file);
|
|
1734
|
+
await writeFile(file, replaced.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
|
|
1735
|
+
return "changed";
|
|
1736
|
+
}
|
|
1737
|
+
await backup(file);
|
|
1738
|
+
await writeFile(file, text.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
|
|
1739
|
+
return "changed";
|
|
1740
|
+
}
|
|
868
1741
|
async function syncSkill(skillDir, skillSrc, remove, log2) {
|
|
869
1742
|
if (remove) {
|
|
870
|
-
if (
|
|
871
|
-
await mkdir(
|
|
1743
|
+
if (existsSync2(skillDir)) {
|
|
1744
|
+
await mkdir(dirname2(skillDir), { recursive: true });
|
|
872
1745
|
await rmRecursive(skillDir);
|
|
873
1746
|
log2(` skill removed: ${skillDir}`);
|
|
874
1747
|
}
|
|
875
1748
|
return;
|
|
876
1749
|
}
|
|
877
|
-
if (!
|
|
1750
|
+
if (!existsSync2(skillSrc)) {
|
|
878
1751
|
log2(` SKILL.md source missing: ${skillSrc} (skipped)`);
|
|
879
1752
|
return;
|
|
880
1753
|
}
|
|
881
1754
|
await mkdir(skillDir, { recursive: true });
|
|
882
|
-
await copyFile(skillSrc,
|
|
883
|
-
log2(` skill -> ${
|
|
1755
|
+
await copyFile(skillSrc, join2(skillDir, "SKILL.md"));
|
|
1756
|
+
log2(` skill -> ${join2(skillDir, "SKILL.md")}`);
|
|
884
1757
|
}
|
|
885
1758
|
async function rmRecursive(dir) {
|
|
886
1759
|
const { rm } = await import("node:fs/promises");
|
|
887
1760
|
await rm(dir, { recursive: true, force: true });
|
|
888
1761
|
}
|
|
1762
|
+
function substitute(entry, values) {
|
|
1763
|
+
const out = {};
|
|
1764
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
1765
|
+
if (typeof value === "string") {
|
|
1766
|
+
out[key] = value.replaceAll("{url}", values.url).replaceAll("{serverName}", values.serverName);
|
|
1767
|
+
} else {
|
|
1768
|
+
out[key] = value;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return out;
|
|
1772
|
+
}
|
|
889
1773
|
async function runSetup(options = {}) {
|
|
890
1774
|
const url = options.url ?? DEFAULT_URL;
|
|
891
1775
|
const serverName = options.serverName ?? DEFAULT_SERVER;
|
|
892
|
-
const home = options.homeDir ??
|
|
1776
|
+
const home = options.homeDir ?? homedir2();
|
|
893
1777
|
const skillSrc = options.skillSrc ?? defaultSkillSrc();
|
|
894
1778
|
const remove = options.remove === true;
|
|
895
1779
|
const log2 = options.log ?? ((message) => console.log(message));
|
|
@@ -899,43 +1783,40 @@ async function runSetup(options = {}) {
|
|
|
899
1783
|
else if (status === "unchanged" || status === "absent") summary.unchanged.push(`${label}: ${file}`);
|
|
900
1784
|
else if (status === "skipped") summary.skipped.push(`${label}: ${file}`);
|
|
901
1785
|
};
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1786
|
+
const registry = loadRegistry();
|
|
1787
|
+
const found = discover(registry, { homeDir: home, pathEnv: options.pathEnv, noNpm: options.noNpm === true });
|
|
1788
|
+
const only = options.agent;
|
|
1789
|
+
let targetAgents = [];
|
|
1790
|
+
if (only !== void 0) {
|
|
1791
|
+
const match = registry.agents.find((agent) => agent.id === only);
|
|
1792
|
+
if (match === void 0) {
|
|
1793
|
+
log2(`agent '${only}' is not in the registry (see agents/registry.json)`);
|
|
1794
|
+
} else {
|
|
1795
|
+
targetAgents = [match];
|
|
1796
|
+
log2(`configure only: ${only}`);
|
|
1797
|
+
}
|
|
1798
|
+
} else {
|
|
1799
|
+
targetAgents = found.filter((agent) => agent.present).map((agent) => registry.agents.find((entry) => entry.id === agent.id));
|
|
1800
|
+
const present2 = targetAgents.map((agent) => agent.id);
|
|
1801
|
+
log2(`discovered: ${present2.length > 0 ? present2.join(", ") : "none"}`);
|
|
1802
|
+
}
|
|
1803
|
+
for (const agent of targetAgents) {
|
|
1804
|
+
for (const config of agent.configs) {
|
|
1805
|
+
for (const file of expandConfigFile(config.file, home)) {
|
|
1806
|
+
try {
|
|
1807
|
+
const status = config.strategy === "json" ? await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName }), { serverName, url, remove }) : config.strategy === "toml" ? await mergeTomlSection(file, { serverName, url, remove }) : await mergeDshPatch(file, { serverName, url, remove });
|
|
1808
|
+
record(status, agent.id, file);
|
|
1809
|
+
} catch (error) {
|
|
1810
|
+
summary.errors.push(`${agent.id}: ${file} \u2014 ${error.message}`);
|
|
1811
|
+
log2(` ${agent.id}: SKIPPED \u2014 ${error.message}`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
917
1814
|
}
|
|
918
1815
|
}
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
} catch (error) {
|
|
924
|
-
summary.errors.push(`codex: ${codexFile} \u2014 ${error.message}`);
|
|
925
|
-
log2(` codex: SKIPPED \u2014 ${error.message}`);
|
|
926
|
-
}
|
|
927
|
-
const skillDirs = [
|
|
928
|
-
join(home, ".agents", "skills", serverName),
|
|
929
|
-
// cross-agent standard
|
|
930
|
-
join(home, ".minimax", "skills", serverName),
|
|
931
|
-
join(home, ".config", "opencode", "skills", serverName),
|
|
932
|
-
join(home, ".kimi-code", "skills", serverName),
|
|
933
|
-
join(home, ".gemini", "skills", serverName),
|
|
934
|
-
join(home, ".codex", "skills", serverName),
|
|
935
|
-
join(home, ".zcode", "skills", serverName),
|
|
936
|
-
join(home, ".claude", "skills", serverName)
|
|
937
|
-
// config is manual; skill still useful
|
|
938
|
-
];
|
|
1816
|
+
const skillDirs = [join2(home, ".agents", "skills", serverName)];
|
|
1817
|
+
for (const agent of targetAgents) {
|
|
1818
|
+
if (agent.skill !== null) skillDirs.push(join2(expandHome(agent.skill, home), serverName));
|
|
1819
|
+
}
|
|
939
1820
|
for (const dir of skillDirs) {
|
|
940
1821
|
try {
|
|
941
1822
|
await syncSkill(dir, skillSrc, remove, log2);
|
|
@@ -944,23 +1825,23 @@ async function runSetup(options = {}) {
|
|
|
944
1825
|
log2(` skill ${dir}: SKIPPED \u2014 ${error.message}`);
|
|
945
1826
|
}
|
|
946
1827
|
}
|
|
947
|
-
if (remove) log2("done. Manual
|
|
948
|
-
else log2("done. Manual
|
|
1828
|
+
if (remove) log2("done. Manual target (see agents/README.md): Claude Code (.mcp.json).");
|
|
1829
|
+
else log2("done. Manual target (see agents/README.md): Claude Code (.mcp.json). Restart agent sessions (and the dsh profile) to pick up the MCP server.");
|
|
949
1830
|
return summary;
|
|
950
1831
|
}
|
|
951
1832
|
|
|
952
1833
|
// src/ops.ts
|
|
953
|
-
import { execFileSync } from "node:child_process";
|
|
1834
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
954
1835
|
import { request } from "node:http";
|
|
955
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
956
|
-
import { homedir as
|
|
957
|
-
import { dirname as
|
|
958
|
-
import { fileURLToPath as
|
|
1836
|
+
import { mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
|
|
1837
|
+
import { homedir as homedir3 } from "node:os";
|
|
1838
|
+
import { dirname as dirname3, join as join3 } from "node:path";
|
|
1839
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
959
1840
|
async function runStatus(options = {}) {
|
|
960
1841
|
const host = options.host ?? "127.0.0.1";
|
|
961
1842
|
const port = options.port ?? 18764;
|
|
962
|
-
const
|
|
963
|
-
const url = options.url ?? `http://${host}:${port}${
|
|
1843
|
+
const path2 = options.path ?? "/mcp";
|
|
1844
|
+
const url = options.url ?? `http://${host}:${port}${path2}`;
|
|
964
1845
|
const probeName = "agent-comm-hub-cli";
|
|
965
1846
|
const notRunning = { running: false, url, peers: [] };
|
|
966
1847
|
try {
|
|
@@ -1007,8 +1888,8 @@ async function runStatus(options = {}) {
|
|
|
1007
1888
|
function runUpdate() {
|
|
1008
1889
|
const messages = [];
|
|
1009
1890
|
try {
|
|
1010
|
-
const pkgFile =
|
|
1011
|
-
const before = JSON.parse(
|
|
1891
|
+
const pkgFile = join3(dirname3(fileURLToPath3(import.meta.url)), "..", "package.json");
|
|
1892
|
+
const before = JSON.parse(readFileSync2(pkgFile, "utf8")).version ?? "?";
|
|
1012
1893
|
messages.push(`current version: ${before}`);
|
|
1013
1894
|
const script = [
|
|
1014
1895
|
"import { execFileSync } from 'node:child_process'",
|
|
@@ -1024,7 +1905,7 @@ function runUpdate() {
|
|
|
1024
1905
|
"if (after === before) console.log('already up to date (v' + after + ')')",
|
|
1025
1906
|
"else console.log('updated: v' + before + ' -> v' + after)"
|
|
1026
1907
|
].join("\n");
|
|
1027
|
-
const out =
|
|
1908
|
+
const out = execFileSync2(process.execPath, ["--input-type=module", "-e", script], { encoding: "utf8", windowsHide: true });
|
|
1028
1909
|
messages.push(out.trim());
|
|
1029
1910
|
messages.push("restart the hub (agent-comm-hub) to pick up the new version");
|
|
1030
1911
|
return { ok: true, messages };
|
|
@@ -1033,30 +1914,31 @@ function runUpdate() {
|
|
|
1033
1914
|
}
|
|
1034
1915
|
}
|
|
1035
1916
|
function cliPath() {
|
|
1036
|
-
return
|
|
1917
|
+
return fileURLToPath3(import.meta.url);
|
|
1037
1918
|
}
|
|
1038
1919
|
function nodeExe() {
|
|
1039
1920
|
return process.execPath;
|
|
1040
1921
|
}
|
|
1041
1922
|
function run(command, args, dryRun) {
|
|
1042
1923
|
if (dryRun) return `[dry-run] ${command} ${args.join(" ")}`;
|
|
1043
|
-
return
|
|
1924
|
+
return execFileSync2(command, args, { encoding: "utf8", windowsHide: true }).trim();
|
|
1044
1925
|
}
|
|
1045
1926
|
function runService(options) {
|
|
1046
1927
|
const messages = [];
|
|
1047
1928
|
const port = options.port ?? 18764;
|
|
1048
1929
|
const host = options.host ?? "127.0.0.1";
|
|
1049
|
-
const
|
|
1930
|
+
const path2 = options.path ?? "/mcp";
|
|
1050
1931
|
const dryRun = options.dryRun === true;
|
|
1932
|
+
const platform = options.platform ?? process.platform;
|
|
1051
1933
|
try {
|
|
1052
|
-
if (
|
|
1053
|
-
const appData = process.env.APPDATA ??
|
|
1054
|
-
const launcherDir =
|
|
1055
|
-
const vbs =
|
|
1934
|
+
if (platform === "win32") {
|
|
1935
|
+
const appData = process.env.APPDATA ?? join3(homedir3(), "AppData", "Roaming");
|
|
1936
|
+
const launcherDir = join3(appData, "agent-comm-hub");
|
|
1937
|
+
const vbs = join3(launcherDir, "agent-comm-hub.vbs");
|
|
1056
1938
|
const runKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
1057
1939
|
const valueName = "agent-comm-hub";
|
|
1058
1940
|
if (options.action === "install") {
|
|
1059
|
-
const cmd = `"${nodeExe()}" "${cliPath()}" --host ${host} --port ${port} --path ${
|
|
1941
|
+
const cmd = `"${nodeExe()}" "${cliPath()}" --host ${host} --port ${port} --path ${path2}`;
|
|
1060
1942
|
const vbsContent = `CreateObject("WScript.Shell").Run "${cmd.replace(/"/g, '""')}", 0, False
|
|
1061
1943
|
`;
|
|
1062
1944
|
if (dryRun) {
|
|
@@ -1065,7 +1947,7 @@ function runService(options) {
|
|
|
1065
1947
|
} else {
|
|
1066
1948
|
mkdirSync(launcherDir, { recursive: true });
|
|
1067
1949
|
writeFileSync(vbs, vbsContent);
|
|
1068
|
-
|
|
1950
|
+
execFileSync2("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
|
|
1069
1951
|
messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
|
|
1070
1952
|
messages.push(`start it now with: wscript.exe "${vbs}"`);
|
|
1071
1953
|
}
|
|
@@ -1075,7 +1957,7 @@ function runService(options) {
|
|
|
1075
1957
|
messages.push(`[dry-run] del ${vbs}`);
|
|
1076
1958
|
} else {
|
|
1077
1959
|
try {
|
|
1078
|
-
|
|
1960
|
+
execFileSync2("reg", ["delete", runKey, "/v", valueName, "/f"], { encoding: "utf8", windowsHide: true });
|
|
1079
1961
|
} catch {
|
|
1080
1962
|
}
|
|
1081
1963
|
rmSync(launcherDir, { recursive: true, force: true });
|
|
@@ -1084,16 +1966,16 @@ function runService(options) {
|
|
|
1084
1966
|
}
|
|
1085
1967
|
return { ok: true, messages };
|
|
1086
1968
|
}
|
|
1087
|
-
if (
|
|
1088
|
-
const unitDir =
|
|
1089
|
-
const unitFile =
|
|
1969
|
+
if (platform === "linux") {
|
|
1970
|
+
const unitDir = join3(homedir3(), ".config", "systemd", "user");
|
|
1971
|
+
const unitFile = join3(unitDir, "agent-comm-hub.service");
|
|
1090
1972
|
if (options.action === "install") {
|
|
1091
1973
|
const unit = `[Unit]
|
|
1092
1974
|
Description=agent-comm-hub (multi-peer MCP hub)
|
|
1093
1975
|
After=network.target
|
|
1094
1976
|
|
|
1095
1977
|
[Service]
|
|
1096
|
-
ExecStart=${nodeExe()} ${cliPath()} --host ${host} --port ${port} --path ${
|
|
1978
|
+
ExecStart=${nodeExe()} ${cliPath()} --host ${host} --port ${port} --path ${path2}
|
|
1097
1979
|
Restart=on-failure
|
|
1098
1980
|
|
|
1099
1981
|
[Install]
|
|
@@ -1121,7 +2003,72 @@ WantedBy=default.target
|
|
|
1121
2003
|
}
|
|
1122
2004
|
return { ok: true, messages };
|
|
1123
2005
|
}
|
|
1124
|
-
|
|
2006
|
+
if (platform === "darwin") {
|
|
2007
|
+
const launchAgentsDir = join3(homedir3(), "Library", "LaunchAgents");
|
|
2008
|
+
const label = "com.agent-comm-hub";
|
|
2009
|
+
const plist = join3(launchAgentsDir, `${label}.plist`);
|
|
2010
|
+
const logFile = join3(homedir3(), "Library", "Logs", "agent-comm-hub.log");
|
|
2011
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
2012
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
2013
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2014
|
+
<plist version="1.0">
|
|
2015
|
+
<dict>
|
|
2016
|
+
<key>Label</key>
|
|
2017
|
+
<string>${label}</string>
|
|
2018
|
+
<key>ProgramArguments</key>
|
|
2019
|
+
<array>
|
|
2020
|
+
<string>${nodeExe()}</string>
|
|
2021
|
+
<string>${cliPath()}</string>
|
|
2022
|
+
<string>--host</string><string>${host}</string>
|
|
2023
|
+
<string>--port</string><string>${port}</string>
|
|
2024
|
+
<string>--path</string><string>${path2}</string>
|
|
2025
|
+
</array>
|
|
2026
|
+
<key>RunAtLoad</key>
|
|
2027
|
+
<true/>
|
|
2028
|
+
<key>KeepAlive</key>
|
|
2029
|
+
<true/>
|
|
2030
|
+
<key>StandardOutPath</key>
|
|
2031
|
+
<string>${logFile}</string>
|
|
2032
|
+
<key>StandardErrorPath</key>
|
|
2033
|
+
<string>${logFile}</string>
|
|
2034
|
+
</dict>
|
|
2035
|
+
</plist>
|
|
2036
|
+
`;
|
|
2037
|
+
if (options.action === "install") {
|
|
2038
|
+
if (dryRun) {
|
|
2039
|
+
messages.push(`[dry-run] write ${plist}`);
|
|
2040
|
+
messages.push(`[dry-run] launchctl bootstrap gui/${uid} ${plist}`);
|
|
2041
|
+
} else {
|
|
2042
|
+
mkdirSync(launchAgentsDir, { recursive: true });
|
|
2043
|
+
writeFileSync(plist, plistContent);
|
|
2044
|
+
try {
|
|
2045
|
+
execFileSync2("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", windowsHide: true });
|
|
2046
|
+
} catch {
|
|
2047
|
+
execFileSync2("launchctl", ["load", "-w", plist], { encoding: "utf8", windowsHide: true });
|
|
2048
|
+
}
|
|
2049
|
+
messages.push(`auto-start registered: launchd LaunchAgent ${plist}`);
|
|
2050
|
+
messages.push(`start it now with: launchctl bootstrap gui/${uid} ${plist}`);
|
|
2051
|
+
}
|
|
2052
|
+
} else {
|
|
2053
|
+
if (dryRun) {
|
|
2054
|
+
messages.push(`[dry-run] launchctl bootout gui/${uid}/${label}`);
|
|
2055
|
+
messages.push(`[dry-run] rm ${plist}`);
|
|
2056
|
+
} else {
|
|
2057
|
+
try {
|
|
2058
|
+
execFileSync2("launchctl", ["bootout", `gui/${uid}/${label}`], { encoding: "utf8", windowsHide: true });
|
|
2059
|
+
} catch {
|
|
2060
|
+
try {
|
|
2061
|
+
execFileSync2("launchctl", ["unload", "-w", plist], { encoding: "utf8", windowsHide: true });
|
|
2062
|
+
} catch {
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
rmSync(plist, { force: true });
|
|
2066
|
+
messages.push("auto-start removed (launchd LaunchAgent)");
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return { ok: true, messages };
|
|
2070
|
+
}
|
|
2071
|
+
return { ok: false, messages: [`auto-start is not implemented for ${platform} \u2014 use pm2 or your platform's supervisor`] };
|
|
1125
2072
|
} catch (error) {
|
|
1126
2073
|
return { ok: false, messages: [`${error.message}`] };
|
|
1127
2074
|
}
|
|
@@ -1130,8 +2077,8 @@ WantedBy=default.target
|
|
|
1130
2077
|
// src/cli.ts
|
|
1131
2078
|
function parseArgs(argv) {
|
|
1132
2079
|
const args = {};
|
|
1133
|
-
const numeric = /* @__PURE__ */ new Set(["--port", "--max-queue", "--history-limit", "--wait-timeout-ms", "--default-wait-ms", "--connected-window-ms", "--peer-idle-timeout-ms"]);
|
|
1134
|
-
const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name"]);
|
|
2080
|
+
const numeric = /* @__PURE__ */ new Set(["--port", "--max-queue", "--history-limit", "--wait-timeout-ms", "--default-wait-ms", "--connected-window-ms", "--peer-idle-timeout-ms", "--herdr-timeout-ms"]);
|
|
2081
|
+
const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin"]);
|
|
1135
2082
|
for (let i = 0; i < argv.length; i++) {
|
|
1136
2083
|
const flag = argv[i];
|
|
1137
2084
|
if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
|
|
@@ -1167,10 +2114,13 @@ Usage:
|
|
|
1167
2114
|
every installed agent (incremental,
|
|
1168
2115
|
idempotent; --remove undoes)
|
|
1169
2116
|
agent-comm-hub status [options] show hub health + online peers
|
|
2117
|
+
agent-comm-hub discover list installed agents (registry-
|
|
2118
|
+
driven; no config changes)
|
|
1170
2119
|
agent-comm-hub service install|uninstall [options]
|
|
1171
2120
|
one-shot auto-start (Windows Run
|
|
1172
2121
|
key + hidden VBS launcher, no admin;
|
|
1173
|
-
Linux systemd
|
|
2122
|
+
Linux systemd, macOS launchd;
|
|
2123
|
+
--dry-run prints)
|
|
1174
2124
|
agent-comm-hub update self-update from the npm registry
|
|
1175
2125
|
(files updated in place; restart
|
|
1176
2126
|
the hub afterwards)
|
|
@@ -1185,10 +2135,14 @@ Hub options:
|
|
|
1185
2135
|
--default-wait-ms <n> bridge_wait default budget (default 30000)
|
|
1186
2136
|
--connected-window-ms <n> Peer counts as active within this window (default 30000)
|
|
1187
2137
|
--peer-idle-timeout-ms <n> Auto-unregister idle peers after this; 0 disables (default 600000)
|
|
2138
|
+
--herdr-bin <path> herdr CLI binary for bridge_agent_* control tools
|
|
2139
|
+
(default herdr, resolved via PATH)
|
|
2140
|
+
--herdr-timeout-ms <n> Default cap for one herdr call in ms (default 30000)
|
|
1188
2141
|
|
|
1189
2142
|
Setup options:
|
|
1190
2143
|
--url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
|
|
1191
2144
|
--server-name <name> Config key (default agent-hub)
|
|
2145
|
+
--agent <id> Only configure one registry agent (e.g. codex)
|
|
1192
2146
|
--remove Uninstall instead of install
|
|
1193
2147
|
|
|
1194
2148
|
-h, --help Show this help
|
|
@@ -1213,11 +2167,16 @@ try {
|
|
|
1213
2167
|
await runSetup({
|
|
1214
2168
|
url: args2["--url"],
|
|
1215
2169
|
serverName: args2["--server-name"],
|
|
2170
|
+
agent: args2["--agent"],
|
|
1216
2171
|
remove: args2["--remove"] === true,
|
|
1217
2172
|
log: (message) => log.info(message)
|
|
1218
2173
|
});
|
|
1219
2174
|
process.exit(0);
|
|
1220
2175
|
}
|
|
2176
|
+
if (command === "discover") {
|
|
2177
|
+
runDiscover({ log: (message) => log.info(message) });
|
|
2178
|
+
process.exit(0);
|
|
2179
|
+
}
|
|
1221
2180
|
if (command === "status") {
|
|
1222
2181
|
const args2 = parseArgs(rest);
|
|
1223
2182
|
const result = await runStatus({
|
|
@@ -1294,7 +2253,9 @@ try {
|
|
|
1294
2253
|
waitTimeoutMs: args["--wait-timeout-ms"],
|
|
1295
2254
|
defaultWaitMs: args["--default-wait-ms"],
|
|
1296
2255
|
connectedWindowMs: args["--connected-window-ms"],
|
|
1297
|
-
peerIdleTimeoutMs: args["--peer-idle-timeout-ms"]
|
|
2256
|
+
peerIdleTimeoutMs: args["--peer-idle-timeout-ms"],
|
|
2257
|
+
herdrBin: args["--herdr-bin"],
|
|
2258
|
+
herdrTimeoutMs: args["--herdr-timeout-ms"]
|
|
1298
2259
|
}, log);
|
|
1299
2260
|
const shutdown = () => {
|
|
1300
2261
|
log.info("agent-comm-hub shutting down");
|