agent-comm-hub 0.2.0 → 0.3.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 +408 -361
- package/README.zh.md +282 -250
- package/lib/cli.js +741 -16
- package/lib/index.js +642 -4
- package/lib/setup.js +88 -4
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -1,6 +1,417 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
|
|
4
|
+
// src/herdr-ctl.ts
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
var AGENT_STATUSES = ["idle", "working", "blocked", "done", "unknown"];
|
|
10
|
+
var HerdrCtl = class {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
this.bin = options.bin ?? "herdr";
|
|
14
|
+
this.baseArgs = options.baseArgs ?? [];
|
|
15
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
|
|
16
|
+
this.socketPath = options.socketPath ?? defaultSocketPath();
|
|
17
|
+
this.sendRequest = options.sendRequest ?? ((method, params) => socketSend(this.socketPath, method, params));
|
|
18
|
+
}
|
|
19
|
+
bin;
|
|
20
|
+
baseArgs;
|
|
21
|
+
defaultTimeoutMs;
|
|
22
|
+
sendRequest;
|
|
23
|
+
socketPath;
|
|
24
|
+
// ---- pane-level control (socket API; works for ANY pane, no agent
|
|
25
|
+
// detection required — this is the channel that drives agents herdr does
|
|
26
|
+
// not recognize, e.g. MiniMax Code) --------------------------------
|
|
27
|
+
/** `pane.list` — every pane in the session (ids, titles, agent states). */
|
|
28
|
+
async paneList() {
|
|
29
|
+
const result = await this.sendRequest("pane.list", {});
|
|
30
|
+
const raw = result.panes;
|
|
31
|
+
if (!Array.isArray(raw)) throw new Error(`herdr pane.list: unexpected result shape: ${JSON.stringify(result)}`);
|
|
32
|
+
return raw.map((item) => toPane(item));
|
|
33
|
+
}
|
|
34
|
+
/** `pane.send_input` — type text into a pane's input (physical input; the
|
|
35
|
+
* pane's program receives it as keystrokes). */
|
|
36
|
+
async paneSendText(paneId, text) {
|
|
37
|
+
if (text === "") throw new Error("paneSendText: text must not be empty");
|
|
38
|
+
await this.sendRequest("pane.send_input", { pane_id: paneId, text });
|
|
39
|
+
}
|
|
40
|
+
/** `pane.send_keys` — raw key presses (Enter, esc, ctrl-c, ...). */
|
|
41
|
+
async paneSendKeys(paneId, keys) {
|
|
42
|
+
if (keys.length === 0) throw new Error("paneSendKeys: at least one key is required");
|
|
43
|
+
await this.sendRequest("pane.send_keys", { pane_id: paneId, keys });
|
|
44
|
+
}
|
|
45
|
+
/** `pane.read` — recent terminal output of a pane. */
|
|
46
|
+
async paneRead(paneId, options = {}) {
|
|
47
|
+
const params = { pane_id: paneId, source: options.source ?? "recent", format: "text", strip_ansi: true };
|
|
48
|
+
if (options.lines !== void 0) params.lines = options.lines;
|
|
49
|
+
const result = await this.sendRequest("pane.read", params);
|
|
50
|
+
const raw = result.read ?? result;
|
|
51
|
+
const read = raw;
|
|
52
|
+
return {
|
|
53
|
+
paneId: String(read.pane_id ?? paneId),
|
|
54
|
+
tabId: read.tab_id === void 0 ? "" : String(read.tab_id),
|
|
55
|
+
workspaceId: read.workspace_id === void 0 ? null : String(read.workspace_id),
|
|
56
|
+
source: String(read.source ?? options.source ?? "recent"),
|
|
57
|
+
text: String(read.text ?? ""),
|
|
58
|
+
revision: Number(read.revision ?? 0),
|
|
59
|
+
truncated: read.truncated === true
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** `pane.wait_for_output` — block until the pane output matches a pattern
|
|
63
|
+
* (substring or regex), or the budget elapses. Returns the matched pane
|
|
64
|
+
* output, or null on timeout. */
|
|
65
|
+
async paneWaitForOutput(paneId, match, options = {}) {
|
|
66
|
+
const params = {
|
|
67
|
+
pane_id: paneId,
|
|
68
|
+
source: options.source ?? "recent",
|
|
69
|
+
match: { type: match.type, value: match.value },
|
|
70
|
+
strip_ansi: true
|
|
71
|
+
};
|
|
72
|
+
if (options.lines !== void 0) params.lines = options.lines;
|
|
73
|
+
if (options.timeoutMs !== void 0) params.timeout_ms = options.timeoutMs;
|
|
74
|
+
let result;
|
|
75
|
+
try {
|
|
76
|
+
result = await this.sendRequest("pane.wait_for_output", params);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (/(timeout|timed out)/i.test(error.message)) return null;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
const raw = result.read ?? result;
|
|
82
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
83
|
+
const read = raw;
|
|
84
|
+
return {
|
|
85
|
+
paneId: String(read.pane_id ?? paneId),
|
|
86
|
+
tabId: read.tab_id === void 0 ? "" : String(read.tab_id),
|
|
87
|
+
workspaceId: read.workspace_id === void 0 ? null : String(read.workspace_id),
|
|
88
|
+
source: String(read.source ?? options.source ?? "recent"),
|
|
89
|
+
text: String(read.text ?? ""),
|
|
90
|
+
revision: Number(read.revision ?? 0),
|
|
91
|
+
truncated: read.truncated === true
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// ---- smart control: use herdr's agent features when the target is
|
|
95
|
+
// recognized, transparently fall back to pane-level control otherwise ---
|
|
96
|
+
/** Detect whether herdr recognizes `target` as an agent (i.e. its
|
|
97
|
+
* agent.get succeeds). Unrecognized panes fall back to pane control. */
|
|
98
|
+
async detectTarget(target) {
|
|
99
|
+
try {
|
|
100
|
+
await this.get(target);
|
|
101
|
+
return "agent";
|
|
102
|
+
} catch {
|
|
103
|
+
return "pane";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Prompt with automatic fallback: agent.prompt for recognized agents
|
|
107
|
+
* (state-machine wait), pane.send_input + output-settling wait otherwise. */
|
|
108
|
+
async promptSmart(target, text, options = {}) {
|
|
109
|
+
if (await this.detectTarget(target) === "agent") {
|
|
110
|
+
const settled2 = await this.prompt(target, text, {
|
|
111
|
+
wait: options.wait,
|
|
112
|
+
until: options.until,
|
|
113
|
+
timeoutMs: options.timeoutMs
|
|
114
|
+
});
|
|
115
|
+
return { via: "agent", settled: settled2 };
|
|
116
|
+
}
|
|
117
|
+
await this.paneSendText(target, text);
|
|
118
|
+
if (options.enter !== false) await this.paneSendKeys(target, ["Enter"]);
|
|
119
|
+
if (options.wait !== true) return { via: "pane", settled: null };
|
|
120
|
+
const budget = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
121
|
+
const settled = await this.waitForPaneSettled(target, budget);
|
|
122
|
+
return { via: "pane", settled };
|
|
123
|
+
}
|
|
124
|
+
/** Wait with automatic fallback: agent.wait (state machine) for recognized
|
|
125
|
+
* agents; pane output-settling heuristic otherwise. */
|
|
126
|
+
async waitSmart(target, options = {}) {
|
|
127
|
+
if (await this.detectTarget(target) === "agent") {
|
|
128
|
+
const settled2 = await this.wait(target, options);
|
|
129
|
+
return { via: "agent", settled: settled2 };
|
|
130
|
+
}
|
|
131
|
+
const budget = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
132
|
+
const settled = await this.waitForPaneSettled(target, budget);
|
|
133
|
+
return { via: "pane", settled };
|
|
134
|
+
}
|
|
135
|
+
/** Read terminal output of a target. Uses the socket `pane.read` for both
|
|
136
|
+
* recognized agents and plain panes: the CLI's `agent read --format text`
|
|
137
|
+
* prints RAW text to stdout (no JSON envelope), so it cannot feed the
|
|
138
|
+
* structured result path — the socket request is uniform and carries
|
|
139
|
+
* revision/truncated metadata. */
|
|
140
|
+
async readSmart(target, options = {}) {
|
|
141
|
+
return this.paneRead(target, options);
|
|
142
|
+
}
|
|
143
|
+
/** Key presses with automatic fallback: agent.send_keys for recognized
|
|
144
|
+
* agents, pane.send_keys otherwise. */
|
|
145
|
+
async keysSmart(target, keys) {
|
|
146
|
+
if (keys.length === 0) throw new Error("keysSmart: at least one key is required");
|
|
147
|
+
if (await this.detectTarget(target) === "agent") {
|
|
148
|
+
await this.sendKeys(target, keys);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await this.paneSendKeys(target, keys);
|
|
152
|
+
}
|
|
153
|
+
/** Status with automatic fallback: agent.get for recognized agents; a
|
|
154
|
+
* pane-derived summary (status unknown) otherwise. */
|
|
155
|
+
async statusSmart(target) {
|
|
156
|
+
try {
|
|
157
|
+
const agent = await this.get(target);
|
|
158
|
+
return { agent, pane: null };
|
|
159
|
+
} catch {
|
|
160
|
+
try {
|
|
161
|
+
const panes = await this.paneList();
|
|
162
|
+
const pane = panes.find((p) => p.paneId === target);
|
|
163
|
+
return { agent: null, pane: pane ?? null };
|
|
164
|
+
} catch {
|
|
165
|
+
return { agent: null, pane: null };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Heuristic settle for unrecognized panes: poll pane.read revisions until
|
|
170
|
+
* the output stops changing for a short window, or the budget elapses. */
|
|
171
|
+
async waitForPaneSettled(paneId, timeoutMs) {
|
|
172
|
+
const startedAt = Date.now();
|
|
173
|
+
let lastRevision = -1;
|
|
174
|
+
let stableTicks = 0;
|
|
175
|
+
const stableTarget = 2;
|
|
176
|
+
const pollMs = 1500;
|
|
177
|
+
for (; ; ) {
|
|
178
|
+
const elapsed = Date.now() - startedAt;
|
|
179
|
+
if (elapsed >= timeoutMs) return null;
|
|
180
|
+
try {
|
|
181
|
+
const read = await this.paneRead(paneId, { source: "recent" });
|
|
182
|
+
if (read.revision === lastRevision) {
|
|
183
|
+
stableTicks++;
|
|
184
|
+
if (stableTicks >= stableTarget) {
|
|
185
|
+
return { paneId, status: "idle", waitedMs: Date.now() - startedAt };
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
stableTicks = 0;
|
|
189
|
+
lastRevision = read.revision;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
}
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// ---- agent-level control (CLI; requires herdr to recognize the agent) ---
|
|
197
|
+
/** `herdr agent list` — every agent pane herdr currently detects. */
|
|
198
|
+
async list() {
|
|
199
|
+
const result = await this.run(["agent", "list"]);
|
|
200
|
+
const raw = result.agents;
|
|
201
|
+
if (!Array.isArray(raw)) throw new Error(`herdr agent list: unexpected result shape: ${JSON.stringify(result)}`);
|
|
202
|
+
return raw.map((item) => toAgent(item));
|
|
203
|
+
}
|
|
204
|
+
/** `herdr agent get <target>` — one agent pane (by paneId or name). */
|
|
205
|
+
async get(target) {
|
|
206
|
+
const result = await this.run(["agent", "get", target]);
|
|
207
|
+
const raw = result.agent;
|
|
208
|
+
if (raw === void 0 || raw === null) throw new Error(`herdr agent get: unexpected result shape: ${JSON.stringify(result)}`);
|
|
209
|
+
return toAgent(raw);
|
|
210
|
+
}
|
|
211
|
+
/** `herdr agent read <target>` — recent terminal output of the pane. */
|
|
212
|
+
async read(target, options = {}) {
|
|
213
|
+
const args = ["agent", "read", target];
|
|
214
|
+
if (options.source !== void 0) args.push("--source", options.source);
|
|
215
|
+
if (options.lines !== void 0) args.push("--lines", String(options.lines));
|
|
216
|
+
args.push("--format", "text");
|
|
217
|
+
const result = await this.run(args);
|
|
218
|
+
const raw = result.read ?? result;
|
|
219
|
+
const read = raw;
|
|
220
|
+
return {
|
|
221
|
+
paneId: String(read.pane_id ?? target),
|
|
222
|
+
tabId: read.tab_id === void 0 ? "" : String(read.tab_id),
|
|
223
|
+
workspaceId: read.workspace_id === void 0 ? null : String(read.workspace_id),
|
|
224
|
+
source: String(read.source ?? options.source ?? "recent"),
|
|
225
|
+
text: String(read.text ?? ""),
|
|
226
|
+
revision: Number(read.revision ?? 0),
|
|
227
|
+
truncated: read.truncated === true
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** `herdr agent send-keys <target> <KEY>...` — raw key presses (Enter,
|
|
231
|
+
* esc, ctrl-c, arrows; named keys are passed through to herdr). */
|
|
232
|
+
async sendKeys(target, keys) {
|
|
233
|
+
if (keys.length === 0) throw new Error("sendKeys: at least one key is required");
|
|
234
|
+
await this.run(["agent", "send-keys", target, ...keys]);
|
|
235
|
+
}
|
|
236
|
+
/** `herdr agent prompt <target> <text>` — submit text to the agent's
|
|
237
|
+
* input line (a slash command like `/compact` is executed by the agent's
|
|
238
|
+
* TUI, not treated as chat content). With `wait`, blocks until the agent
|
|
239
|
+
* settles (default idle/done/blocked, or the exact `until` states) or
|
|
240
|
+
* `timeoutMs` elapses. */
|
|
241
|
+
async prompt(target, text, options = {}) {
|
|
242
|
+
const args = ["agent", "prompt", target, text];
|
|
243
|
+
if (options.wait === true) args.push("--wait");
|
|
244
|
+
for (const status of options.until ?? []) args.push("--until", status);
|
|
245
|
+
if (options.timeoutMs !== void 0) args.push("--timeout", String(options.timeoutMs));
|
|
246
|
+
const result = await this.run(args, options.timeoutMs);
|
|
247
|
+
return settleFrom(result, target);
|
|
248
|
+
}
|
|
249
|
+
/** `herdr agent wait <target>` — block until the agent reaches one of the
|
|
250
|
+
* requested states (default idle/done/blocked) or `timeoutMs` elapses. */
|
|
251
|
+
async wait(target, options = {}) {
|
|
252
|
+
const args = ["agent", "wait", target];
|
|
253
|
+
for (const status of options.until ?? []) args.push("--until", status);
|
|
254
|
+
if (options.timeoutMs !== void 0) args.push("--timeout", String(options.timeoutMs));
|
|
255
|
+
const result = await this.run(args, options.timeoutMs);
|
|
256
|
+
return settleFrom(result, target);
|
|
257
|
+
}
|
|
258
|
+
/** Run one herdr CLI invocation and parse its JSON envelope. */
|
|
259
|
+
run(args, timeoutMs) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
execFile(
|
|
262
|
+
this.bin,
|
|
263
|
+
[...this.baseArgs, ...args],
|
|
264
|
+
{ timeout: timeoutMs ?? this.defaultTimeoutMs, windowsHide: true, maxBuffer: 16 * 1024 * 1024 },
|
|
265
|
+
(error, stdout, stderr) => {
|
|
266
|
+
if (error) {
|
|
267
|
+
const err = error;
|
|
268
|
+
if (err.code === "ENOENT") {
|
|
269
|
+
reject(new Error(`herdr CLI not found ('${this.bin}') \u2014 install herdr (https://herdr.dev) or point --herdr-bin at it`));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const payload = stdout || stderr || "";
|
|
273
|
+
let envelope = null;
|
|
274
|
+
try {
|
|
275
|
+
envelope = JSON.parse(payload);
|
|
276
|
+
} catch {
|
|
277
|
+
}
|
|
278
|
+
if (envelope !== null && typeof envelope === "object" && envelope.error !== void 0) {
|
|
279
|
+
reject(new Error(`${envelope.error.code ?? "herdr error"}: ${envelope.error.message ?? "unknown error"}`));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const hint = payload.trim() !== "" ? ` \u2014 ${payload.trim().slice(0, 200)}` : "";
|
|
283
|
+
reject(new Error(`herdr ${args.slice(0, 2).join(" ")} failed${hint}`));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
let parsed = null;
|
|
287
|
+
try {
|
|
288
|
+
parsed = JSON.parse(stdout);
|
|
289
|
+
} catch {
|
|
290
|
+
}
|
|
291
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
292
|
+
reject(new Error(`herdr returned non-JSON output: ${stdout.slice(0, 200)}`));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (parsed.error !== void 0) {
|
|
296
|
+
reject(new Error(`${parsed.error.code ?? "herdr error"}: ${parsed.error.message ?? "unknown error"}`));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
resolve(parsed.result);
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
function toAgent(raw) {
|
|
306
|
+
const status = raw.agent_status;
|
|
307
|
+
return {
|
|
308
|
+
paneId: String(raw.pane_id ?? ""),
|
|
309
|
+
tabId: raw.tab_id === void 0 ? "" : String(raw.tab_id),
|
|
310
|
+
terminalId: raw.terminal_id === void 0 ? "" : String(raw.terminal_id),
|
|
311
|
+
name: raw.name === void 0 ? null : String(raw.name),
|
|
312
|
+
agent: raw.agent === void 0 ? null : String(raw.agent),
|
|
313
|
+
displayAgent: raw.display_agent === void 0 ? null : String(raw.display_agent),
|
|
314
|
+
status: AGENT_STATUSES.includes(status) ? status : "unknown",
|
|
315
|
+
cwd: raw.cwd === void 0 ? null : String(raw.cwd),
|
|
316
|
+
focused: raw.focused === true,
|
|
317
|
+
interactiveReady: raw.interactive_ready === true,
|
|
318
|
+
launchPending: raw.launch_pending === true,
|
|
319
|
+
terminalTitle: raw.terminal_title === void 0 ? null : String(raw.terminal_title),
|
|
320
|
+
revision: Number(raw.revision ?? 0)
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function settleFrom(result, target) {
|
|
324
|
+
if (result === null || typeof result !== "object") return null;
|
|
325
|
+
const obj = result;
|
|
326
|
+
const event = obj.event;
|
|
327
|
+
const status = event?.agent_status ?? obj.agent_status ?? obj.status;
|
|
328
|
+
if (AGENT_STATUSES.includes(status)) {
|
|
329
|
+
return {
|
|
330
|
+
paneId: String(event?.pane_id ?? obj.pane_id ?? target),
|
|
331
|
+
status,
|
|
332
|
+
waitedMs: Number(obj.waited_ms ?? null) || null
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
function toPane(raw) {
|
|
338
|
+
const status = raw.agent_status;
|
|
339
|
+
return {
|
|
340
|
+
paneId: String(raw.pane_id ?? ""),
|
|
341
|
+
tabId: raw.tab_id === void 0 ? "" : String(raw.tab_id),
|
|
342
|
+
workspaceId: raw.workspace_id === void 0 ? "" : String(raw.workspace_id),
|
|
343
|
+
terminalId: raw.terminal_id === void 0 ? "" : String(raw.terminal_id),
|
|
344
|
+
title: raw.terminal_title_stripped === void 0 ? null : String(raw.terminal_title_stripped),
|
|
345
|
+
agentStatus: AGENT_STATUSES.includes(status) ? status : "unknown",
|
|
346
|
+
cwd: raw.cwd === void 0 ? null : String(raw.cwd),
|
|
347
|
+
focused: raw.focused === true,
|
|
348
|
+
revision: Number(raw.revision ?? 0)
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function defaultSocketPath() {
|
|
352
|
+
if (process.platform === "win32") {
|
|
353
|
+
const base = process.env.APPDATA ?? process.env.USERPROFILE ?? ".";
|
|
354
|
+
return `\\\\.\\pipe\\${path.join(base, "herdr", "herdr.sock")}`;
|
|
355
|
+
}
|
|
356
|
+
return path.join(os.homedir(), ".config", "herdr", "herdr.sock");
|
|
357
|
+
}
|
|
358
|
+
function socketSend(socketPath, method, params) {
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
const socket = net.createConnection(socketPath);
|
|
361
|
+
let buffer = "";
|
|
362
|
+
let settled = false;
|
|
363
|
+
const fail = (error) => {
|
|
364
|
+
if (settled) return;
|
|
365
|
+
settled = true;
|
|
366
|
+
socket.destroy();
|
|
367
|
+
reject(error);
|
|
368
|
+
};
|
|
369
|
+
const timeout = setTimeout(() => fail(new Error(`herdr socket request timed out: ${method}`)), 15e3);
|
|
370
|
+
socket.on("connect", () => {
|
|
371
|
+
socket.write(JSON.stringify({ id: `dsh-${Date.now()}`, method, params }) + "\n");
|
|
372
|
+
});
|
|
373
|
+
socket.on("data", (chunk) => {
|
|
374
|
+
buffer += chunk.toString("utf8");
|
|
375
|
+
const lines = buffer.split("\n");
|
|
376
|
+
buffer = lines.pop() ?? "";
|
|
377
|
+
for (const line of lines) {
|
|
378
|
+
if (line.trim() === "") continue;
|
|
379
|
+
let envelope;
|
|
380
|
+
try {
|
|
381
|
+
envelope = JSON.parse(line);
|
|
382
|
+
} catch {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (settled) continue;
|
|
386
|
+
settled = true;
|
|
387
|
+
clearTimeout(timeout);
|
|
388
|
+
socket.destroy();
|
|
389
|
+
if (envelope.error !== void 0) {
|
|
390
|
+
reject(new Error(`${envelope.error.code ?? "herdr error"}: ${envelope.error.message ?? "unknown error"}`));
|
|
391
|
+
} else {
|
|
392
|
+
resolve(envelope.result);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
socket.on("error", (error) => fail(error));
|
|
397
|
+
socket.on("close", () => {
|
|
398
|
+
if (!settled && buffer.trim() !== "") {
|
|
399
|
+
try {
|
|
400
|
+
const envelope = JSON.parse(buffer);
|
|
401
|
+
settled = true;
|
|
402
|
+
clearTimeout(timeout);
|
|
403
|
+
if (envelope.error !== void 0) reject(new Error(`${envelope.error.code ?? "herdr error"}: ${envelope.error.message ?? "unknown error"}`));
|
|
404
|
+
else resolve(envelope.result);
|
|
405
|
+
} catch {
|
|
406
|
+
fail(new Error(`herdr socket closed without a response: ${method}`));
|
|
407
|
+
}
|
|
408
|
+
} else if (!settled) {
|
|
409
|
+
fail(new Error(`herdr socket closed without a response: ${method}`));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
4
415
|
// src/hub.ts
|
|
5
416
|
import { randomUUID } from "node:crypto";
|
|
6
417
|
|
|
@@ -294,6 +705,22 @@ function hubTools(hub, registry, options) {
|
|
|
294
705
|
ts: message.ts
|
|
295
706
|
});
|
|
296
707
|
const presentWait = (result) => result.type === "timeout" ? result : { type: "message", message: present(result.message) };
|
|
708
|
+
const checkControl = (peer) => {
|
|
709
|
+
const herdr = options.herdr;
|
|
710
|
+
if (herdr === void 0) {
|
|
711
|
+
throw new Error("herdr control not enabled \u2014 start the hub with --herdr-bin or pass a herdrCtl to hubTools");
|
|
712
|
+
}
|
|
713
|
+
const control = options.herdrControlPeers ?? "all";
|
|
714
|
+
if (control !== "all" && !control.has(peer)) {
|
|
715
|
+
throw new Error(`peer '${peer}' is not allowed to use bridge_agent_* tools`);
|
|
716
|
+
}
|
|
717
|
+
return herdr;
|
|
718
|
+
};
|
|
719
|
+
const asStatuses = (value) => {
|
|
720
|
+
if (!Array.isArray(value)) return void 0;
|
|
721
|
+
const statuses = value.map(String).filter((status) => AGENT_STATUSES.includes(status));
|
|
722
|
+
return statuses.length > 0 ? statuses : void 0;
|
|
723
|
+
};
|
|
297
724
|
const wrap = (peerAware, handler) => async (args, sessionId) => {
|
|
298
725
|
const peer = peerAware ? requirePeer(sessionId) : "";
|
|
299
726
|
return handler(args, peer, sessionId);
|
|
@@ -419,6 +846,203 @@ function hubTools(hub, registry, options) {
|
|
|
419
846
|
description: "Recent messages involving you (newest first); pass `peer` to inspect another peer's conversation. Use to refresh context after a reconnect.",
|
|
420
847
|
inputSchema: schema({ peer: optStr("PeerId whose conversation to inspect; default: yourself."), limit: int("How many messages to return (default 20).") }),
|
|
421
848
|
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) }))
|
|
849
|
+
},
|
|
850
|
+
// ---- herdr control tools ------------------------------------------
|
|
851
|
+
// These type into real agent terminals via the herdr runtime. They are
|
|
852
|
+
// gated by checkControl and documented as physical input: unlike
|
|
853
|
+
// bridge_chat (a mailbox message the model may ignore), a prompt here is
|
|
854
|
+
// executed by the target's TUI — slash commands included.
|
|
855
|
+
{
|
|
856
|
+
name: "bridge_agent_list",
|
|
857
|
+
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.",
|
|
858
|
+
inputSchema: schema({}),
|
|
859
|
+
handler: wrap(true, async (_args, peer) => ({ agents: await checkControl(peer).list() }))
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
name: "bridge_agent_status",
|
|
863
|
+
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.',
|
|
864
|
+
inputSchema: schema({ target: str("herdr paneId, e.g. w1:p1, from bridge_agent_list or bridge_pane_list.") }, ["target"]),
|
|
865
|
+
handler: wrap(true, async (args, peer) => {
|
|
866
|
+
const result = await checkControl(peer).statusSmart(String(args.target));
|
|
867
|
+
return { agent: result.agent, ...result.pane !== null ? { pane: result.pane } : {} };
|
|
868
|
+
})
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
name: "bridge_agent_prompt",
|
|
872
|
+
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.',
|
|
873
|
+
inputSchema: schema(
|
|
874
|
+
{
|
|
875
|
+
target: str("herdr paneId, e.g. w1:p1, from bridge_pane_list / bridge_agent_list."),
|
|
876
|
+
text: str("Text to submit (slash commands are executed, not sent as chat)."),
|
|
877
|
+
wait: { type: "boolean", description: "Wait for the agent to settle after submission (default false)." },
|
|
878
|
+
until: { type: "array", items: { type: "string", enum: [...AGENT_STATUSES] }, description: "Exact states to wait for (agent channel; default: idle/done/blocked)." },
|
|
879
|
+
timeoutMs: int("Wait cap in ms (default 30000).")
|
|
880
|
+
},
|
|
881
|
+
["target", "text"]
|
|
882
|
+
),
|
|
883
|
+
handler: wrap(true, async (args, peer) => {
|
|
884
|
+
const ctl = checkControl(peer);
|
|
885
|
+
const waiting = args.wait === true;
|
|
886
|
+
const result = await ctl.promptSmart(String(args.target), String(args.text), {
|
|
887
|
+
wait: waiting,
|
|
888
|
+
until: asStatuses(args.until),
|
|
889
|
+
timeoutMs: args.timeoutMs === void 0 ? void 0 : Number(args.timeoutMs)
|
|
890
|
+
});
|
|
891
|
+
return waiting ? { submitted: true, via: result.via, settled: result.settled } : { submitted: true, via: result.via };
|
|
892
|
+
})
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
name: "bridge_agent_wait",
|
|
896
|
+
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.',
|
|
897
|
+
inputSchema: schema(
|
|
898
|
+
{
|
|
899
|
+
target: str("herdr paneId, e.g. w1:p1, from bridge_pane_list / bridge_agent_list."),
|
|
900
|
+
until: { type: "array", items: { type: "string", enum: [...AGENT_STATUSES] }, description: "Exact states to wait for (agent channel; default: idle/done/blocked)." },
|
|
901
|
+
timeoutMs: int("Wait cap in ms (default 30000).")
|
|
902
|
+
},
|
|
903
|
+
["target"]
|
|
904
|
+
),
|
|
905
|
+
handler: wrap(true, async (args, peer) => {
|
|
906
|
+
const result = await checkControl(peer).waitSmart(String(args.target), {
|
|
907
|
+
until: asStatuses(args.until),
|
|
908
|
+
timeoutMs: args.timeoutMs === void 0 ? void 0 : Number(args.timeoutMs)
|
|
909
|
+
});
|
|
910
|
+
return { via: result.via, settled: result.settled };
|
|
911
|
+
})
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
name: "bridge_agent_read",
|
|
915
|
+
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).",
|
|
916
|
+
inputSchema: schema(
|
|
917
|
+
{
|
|
918
|
+
target: str("herdr paneId, e.g. w1:p1, from bridge_pane_list / bridge_agent_list."),
|
|
919
|
+
lines: int("How many lines to read (default: all recent)."),
|
|
920
|
+
source: { type: "string", enum: ["visible", "recent", "recent-unwrapped", "detection"], description: "Terminal snapshot source (default recent)." }
|
|
921
|
+
},
|
|
922
|
+
["target"]
|
|
923
|
+
),
|
|
924
|
+
handler: wrap(
|
|
925
|
+
true,
|
|
926
|
+
async (args, peer) => checkControl(peer).readSmart(String(args.target), {
|
|
927
|
+
lines: args.lines === void 0 ? void 0 : Number(args.lines),
|
|
928
|
+
source: args.source === void 0 ? void 0 : args.source
|
|
929
|
+
})
|
|
930
|
+
)
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
name: "bridge_agent_keys",
|
|
934
|
+
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.",
|
|
935
|
+
inputSchema: schema(
|
|
936
|
+
{
|
|
937
|
+
target: str("herdr paneId, e.g. w1:p1, from bridge_pane_list / bridge_agent_list."),
|
|
938
|
+
keys: { type: "array", items: { type: "string" }, description: 'Keys to send, e.g. ["Enter"], ["esc"], ["ctrl-c", "Enter"].' }
|
|
939
|
+
},
|
|
940
|
+
["target", "keys"]
|
|
941
|
+
),
|
|
942
|
+
handler: wrap(true, async (args, peer) => {
|
|
943
|
+
const ctl = checkControl(peer);
|
|
944
|
+
const keys = Array.isArray(args.keys) ? args.keys.map(String) : [];
|
|
945
|
+
if (keys.length === 0) throw new Error("keys: at least one key is required");
|
|
946
|
+
await ctl.keysSmart(String(args.target), keys);
|
|
947
|
+
return { ok: true, sent: keys };
|
|
948
|
+
})
|
|
949
|
+
},
|
|
950
|
+
// ---- pane-level control tools (socket API) -----------------------
|
|
951
|
+
// Unlike the bridge_agent_* tools (which require herdr to RECOGNIZE the
|
|
952
|
+
// agent), these drive any pane through the herdr local socket: physical
|
|
953
|
+
// input and output for agents herdr does not know (e.g. MiniMax Code).
|
|
954
|
+
{
|
|
955
|
+
name: "bridge_pane_list",
|
|
956
|
+
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.",
|
|
957
|
+
inputSchema: schema({}),
|
|
958
|
+
handler: wrap(true, async (_args, peer) => ({ panes: await checkControl(peer).paneList() }))
|
|
959
|
+
},
|
|
960
|
+
{
|
|
961
|
+
name: "bridge_pane_send",
|
|
962
|
+
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.",
|
|
963
|
+
inputSchema: schema(
|
|
964
|
+
{
|
|
965
|
+
target: str("herdr paneId, e.g. wT:p2, from bridge_pane_list."),
|
|
966
|
+
text: str("Text to type into the pane (slash commands are executed, not sent as chat)."),
|
|
967
|
+
enter: { type: "boolean", description: "Submit with Enter after typing (default true)." }
|
|
968
|
+
},
|
|
969
|
+
["target", "text"]
|
|
970
|
+
),
|
|
971
|
+
handler: wrap(true, async (args, peer) => {
|
|
972
|
+
const ctl = checkControl(peer);
|
|
973
|
+
const paneId = String(args.target);
|
|
974
|
+
const text = String(args.text);
|
|
975
|
+
await ctl.paneSendText(paneId, text);
|
|
976
|
+
if (args.enter !== false) await ctl.paneSendKeys(paneId, ["Enter"]);
|
|
977
|
+
return { ok: true, target: paneId, sent: text };
|
|
978
|
+
})
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
name: "bridge_pane_keys",
|
|
982
|
+
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.",
|
|
983
|
+
inputSchema: schema(
|
|
984
|
+
{
|
|
985
|
+
target: str("herdr paneId, e.g. wT:p2, from bridge_pane_list."),
|
|
986
|
+
keys: { type: "array", items: { type: "string" }, description: 'Keys to send, e.g. ["Enter"], ["esc"], ["ctrl-c"].' }
|
|
987
|
+
},
|
|
988
|
+
["target", "keys"]
|
|
989
|
+
),
|
|
990
|
+
handler: wrap(true, async (args, peer) => {
|
|
991
|
+
const ctl = checkControl(peer);
|
|
992
|
+
const keys = Array.isArray(args.keys) ? args.keys.map(String) : [];
|
|
993
|
+
if (keys.length === 0) throw new Error("keys: at least one key is required");
|
|
994
|
+
await ctl.paneSendKeys(String(args.target), keys);
|
|
995
|
+
return { ok: true, sent: keys };
|
|
996
|
+
})
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
name: "bridge_pane_read",
|
|
1000
|
+
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.",
|
|
1001
|
+
inputSchema: schema(
|
|
1002
|
+
{
|
|
1003
|
+
target: str("herdr paneId, e.g. wT:p2, from bridge_pane_list."),
|
|
1004
|
+
lines: int("How many lines to read (default: all recent)."),
|
|
1005
|
+
source: { type: "string", enum: ["visible", "recent", "recent-unwrapped", "detection"], description: "Terminal snapshot source (default recent)." }
|
|
1006
|
+
},
|
|
1007
|
+
["target"]
|
|
1008
|
+
),
|
|
1009
|
+
handler: wrap(
|
|
1010
|
+
true,
|
|
1011
|
+
async (args, peer) => checkControl(peer).paneRead(String(args.target), {
|
|
1012
|
+
lines: args.lines === void 0 ? void 0 : Number(args.lines),
|
|
1013
|
+
source: args.source === void 0 ? void 0 : args.source
|
|
1014
|
+
})
|
|
1015
|
+
)
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
name: "bridge_pane_wait",
|
|
1019
|
+
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.",
|
|
1020
|
+
inputSchema: schema(
|
|
1021
|
+
{
|
|
1022
|
+
target: str("herdr paneId, e.g. wT:p2, from bridge_pane_list."),
|
|
1023
|
+
match: {
|
|
1024
|
+
type: "object",
|
|
1025
|
+
properties: {
|
|
1026
|
+
type: { type: "string", enum: ["substring", "regex"], description: "Match kind (default substring)." },
|
|
1027
|
+
value: { type: "string", description: "Text or regex to match in the pane output." }
|
|
1028
|
+
},
|
|
1029
|
+
required: ["value"],
|
|
1030
|
+
description: "Output pattern to wait for."
|
|
1031
|
+
},
|
|
1032
|
+
timeoutMs: int("Wait cap in ms (default 30000).")
|
|
1033
|
+
},
|
|
1034
|
+
["target", "match"]
|
|
1035
|
+
),
|
|
1036
|
+
handler: wrap(true, async (args, peer) => {
|
|
1037
|
+
const ctl = checkControl(peer);
|
|
1038
|
+
const match = args.match ?? {};
|
|
1039
|
+
if (typeof match.value !== "string" || match.value === "") throw new Error("match.value: a non-empty string is required");
|
|
1040
|
+
const type = match.type === "regex" ? "regex" : "substring";
|
|
1041
|
+
const read = await ctl.paneWaitForOutput(String(args.target), { type, value: match.value }, {
|
|
1042
|
+
timeoutMs: args.timeoutMs === void 0 ? void 0 : Number(args.timeoutMs)
|
|
1043
|
+
});
|
|
1044
|
+
return read === null ? { matched: null } : { matched: read };
|
|
1045
|
+
})
|
|
422
1046
|
}
|
|
423
1047
|
];
|
|
424
1048
|
}
|
|
@@ -530,10 +1154,10 @@ var McpStreamableHttpServer = class {
|
|
|
530
1154
|
}
|
|
531
1155
|
sseStreams = /* @__PURE__ */ new Map();
|
|
532
1156
|
/** Attach request handling for `path` (e.g. `/mcp`) to an http server. */
|
|
533
|
-
attach(server,
|
|
1157
|
+
attach(server, path2) {
|
|
534
1158
|
server.on("request", (req, res) => {
|
|
535
1159
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
536
|
-
if (url.pathname !==
|
|
1160
|
+
if (url.pathname !== path2) {
|
|
537
1161
|
res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not found" }));
|
|
538
1162
|
return;
|
|
539
1163
|
}
|
|
@@ -713,7 +1337,7 @@ function readBody(req) {
|
|
|
713
1337
|
|
|
714
1338
|
// src/index.ts
|
|
715
1339
|
var SERVER_NAME = "agent-comm-hub";
|
|
716
|
-
var SERVER_VERSION = "0.
|
|
1340
|
+
var SERVER_VERSION = "0.3.0";
|
|
717
1341
|
var DEFAULT_HOST = "127.0.0.1";
|
|
718
1342
|
var DEFAULT_PORT = 18764;
|
|
719
1343
|
var DEFAULT_PATH = "/mcp";
|
|
@@ -742,8 +1366,20 @@ function startHub(config = {}, log = console) {
|
|
|
742
1366
|
isPeerLive: (peerId) => livePeersFor(registry).has(peerId)
|
|
743
1367
|
});
|
|
744
1368
|
const registry = new SessionRegistry();
|
|
1369
|
+
const herdr = new HerdrCtl({
|
|
1370
|
+
bin: resolved.herdrBin,
|
|
1371
|
+
baseArgs: resolved.herdrBaseArgs,
|
|
1372
|
+
defaultTimeoutMs: resolved.herdrTimeoutMs,
|
|
1373
|
+
socketPath: resolved.herdrSocketPath,
|
|
1374
|
+
sendRequest: resolved.herdrSendRequest
|
|
1375
|
+
});
|
|
745
1376
|
const mcp = new McpStreamableHttpServer(
|
|
746
|
-
hubTools(hub, registry, {
|
|
1377
|
+
hubTools(hub, registry, {
|
|
1378
|
+
defaultWaitMs: resolved.defaultWaitMs,
|
|
1379
|
+
waitTimeoutMs: resolved.waitTimeoutMs,
|
|
1380
|
+
herdr,
|
|
1381
|
+
herdrControlPeers: resolved.herdrControlPeers === "all" || resolved.herdrControlPeers === void 0 ? "all" : new Set(resolved.herdrControlPeers)
|
|
1382
|
+
}),
|
|
747
1383
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
748
1384
|
registry,
|
|
749
1385
|
(message) => log.warn(message),
|
|
@@ -776,12 +1412,14 @@ function startHub(config = {}, log = console) {
|
|
|
776
1412
|
};
|
|
777
1413
|
}
|
|
778
1414
|
export {
|
|
1415
|
+
AGENT_STATUSES,
|
|
779
1416
|
AgentHub,
|
|
780
1417
|
BROADCAST,
|
|
781
1418
|
DEFAULT_CONFIG,
|
|
782
1419
|
DEFAULT_HOST,
|
|
783
1420
|
DEFAULT_PATH,
|
|
784
1421
|
DEFAULT_PORT,
|
|
1422
|
+
HerdrCtl,
|
|
785
1423
|
KINDS,
|
|
786
1424
|
McpStreamableHttpServer,
|
|
787
1425
|
PEER_ID_PATTERN,
|