herdr-link 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/PROTOCOL.md +39 -19
- package/README.md +116 -12
- package/README.zh-CN.md +116 -12
- package/dist/herdr-link.mcp.js +225 -17
- package/dist/herdr-link.opencode.js +206 -10
- package/docs/mcp-wiring.md +18 -15
- package/examples/agent_config.example.json +41 -0
- package/package.json +5 -3
- package/src/herdr.ts +231 -0
- package/src/mcp.ts +56 -27
- package/src/opencode.ts +43 -11
- package/src/pi.ts +46 -11
- package/src/protocol.ts +49 -2
package/src/herdr.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { resolve } from "node:path";
|
|
3
5
|
import {
|
|
4
6
|
AGENT_ERROR_DETAILS,
|
|
5
7
|
buildEnvelope,
|
|
@@ -12,6 +14,8 @@ import {
|
|
|
12
14
|
type LinkErrorCode,
|
|
13
15
|
type PeerDirectory,
|
|
14
16
|
type PeerInfo,
|
|
17
|
+
type StartAgentInput,
|
|
18
|
+
type StartAgentReceipt,
|
|
15
19
|
} from "./protocol.ts";
|
|
16
20
|
|
|
17
21
|
export interface HerdrCommandOutput {
|
|
@@ -99,6 +103,233 @@ async function runFor(args: string[], failureCode: LinkErrorCode): Promise<unkno
|
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
interface ValidatedStartVariant {
|
|
107
|
+
kind: string;
|
|
108
|
+
args: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ConfiguredStartAgent {
|
|
112
|
+
strategy?: "round-robin";
|
|
113
|
+
variants: ValidatedStartVariant[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const startCursors = new Map<string, number>();
|
|
117
|
+
const startLocks = new Map<string, Promise<void>>();
|
|
118
|
+
const START_CONFIG_PATH_PARTS = [".agents", "agent_config.json"] as const;
|
|
119
|
+
const START_INPUT_KEYS = new Set(["name", "pane", "config_agent", "kind", "args"]);
|
|
120
|
+
|
|
121
|
+
/** @internal Test seam only: clears process-local configured-start state. */
|
|
122
|
+
export function resetStartStateForTests(): void {
|
|
123
|
+
startCursors.clear();
|
|
124
|
+
startLocks.clear();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
128
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function startInputError(detail: string): HerdrLinkError {
|
|
132
|
+
return new HerdrLinkError("START_INPUT_INVALID", detail);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function startConfigError(
|
|
136
|
+
code: "START_CONFIG_NOT_FOUND" | "START_AGENT_NOT_FOUND" | "START_CONFIG_INVALID",
|
|
137
|
+
detail: string,
|
|
138
|
+
): HerdrLinkError {
|
|
139
|
+
return new HerdrLinkError(code, detail);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateStartInput(input: unknown):
|
|
143
|
+
| { mode: "configured"; name: string; pane: string; configAgent: string }
|
|
144
|
+
| { mode: "explicit"; name: string; pane: string; variant: ValidatedStartVariant } {
|
|
145
|
+
const value = asRecord(input);
|
|
146
|
+
if (!value) throw startInputError("start input must be an object");
|
|
147
|
+
for (const key of Object.keys(value)) {
|
|
148
|
+
if (!START_INPUT_KEYS.has(key)) throw startInputError(`unknown start field "${key}"`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const name = value.name;
|
|
152
|
+
if (typeof name !== "string" || !isValidAgentName(name)) {
|
|
153
|
+
throw startInputError("\"name\" must be a valid Herdr Agent Name");
|
|
154
|
+
}
|
|
155
|
+
const pane = value.pane;
|
|
156
|
+
if (typeof pane !== "string" || pane.trim() === "") {
|
|
157
|
+
throw startInputError("\"pane\" must be a non-empty pane id");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const hasConfigAgent = hasOwn(value, "config_agent");
|
|
161
|
+
const hasKind = hasOwn(value, "kind");
|
|
162
|
+
const hasArgs = hasOwn(value, "args");
|
|
163
|
+
if (hasConfigAgent && (hasKind || hasArgs)) {
|
|
164
|
+
throw startInputError("config_agent cannot be combined with kind or args");
|
|
165
|
+
}
|
|
166
|
+
if (hasConfigAgent) {
|
|
167
|
+
const configAgent = value.config_agent;
|
|
168
|
+
if (typeof configAgent !== "string" || configAgent.trim() === "") {
|
|
169
|
+
throw startInputError("\"config_agent\" must be a non-empty string");
|
|
170
|
+
}
|
|
171
|
+
return { mode: "configured", name, pane, configAgent };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!hasKind || !hasArgs) {
|
|
175
|
+
throw startInputError("explicit start requires both kind and args");
|
|
176
|
+
}
|
|
177
|
+
const kind = value.kind;
|
|
178
|
+
if (typeof kind !== "string" || kind.trim() === "") {
|
|
179
|
+
throw startInputError("\"kind\" must be a non-empty string");
|
|
180
|
+
}
|
|
181
|
+
const args = value.args;
|
|
182
|
+
if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) {
|
|
183
|
+
throw startInputError("\"args\" must be an array of strings");
|
|
184
|
+
}
|
|
185
|
+
return { mode: "explicit", name, pane, variant: { kind, args: [...args] } };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function assertAllowedKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
|
|
189
|
+
const allowedSet = new Set(allowed);
|
|
190
|
+
for (const key of Object.keys(value)) {
|
|
191
|
+
if (!allowedSet.has(key)) throw startConfigError("START_CONFIG_INVALID", `${label} contains unknown field "${key}"`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validateConfiguredDocument(document: unknown): Map<string, ConfiguredStartAgent> {
|
|
196
|
+
const root = asRecord(document);
|
|
197
|
+
if (!root) throw startConfigError("START_CONFIG_INVALID", "configuration root must be an object");
|
|
198
|
+
assertAllowedKeys(root, ["version", "agents"], "configuration root");
|
|
199
|
+
if (root.version !== 1) throw startConfigError("START_CONFIG_INVALID", "configuration version must be 1");
|
|
200
|
+
|
|
201
|
+
const agents = asRecord(root.agents);
|
|
202
|
+
if (!agents) throw startConfigError("START_CONFIG_INVALID", "agents must be an object");
|
|
203
|
+
|
|
204
|
+
const result = new Map<string, ConfiguredStartAgent>();
|
|
205
|
+
for (const [configAgent, rawEntry] of Object.entries(agents)) {
|
|
206
|
+
if (configAgent.trim() === "") {
|
|
207
|
+
throw startConfigError("START_CONFIG_INVALID", "agents contains an empty configuration key");
|
|
208
|
+
}
|
|
209
|
+
const entry = asRecord(rawEntry);
|
|
210
|
+
if (!entry) throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent} must be an object`);
|
|
211
|
+
assertAllowedKeys(entry, ["strategy", "variants"], `agents.${configAgent}`);
|
|
212
|
+
|
|
213
|
+
const rawVariants = entry.variants;
|
|
214
|
+
if (!Array.isArray(rawVariants) || rawVariants.length === 0) {
|
|
215
|
+
throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants must be non-empty`);
|
|
216
|
+
}
|
|
217
|
+
const hasStrategy = hasOwn(entry, "strategy");
|
|
218
|
+
if (hasStrategy && entry.strategy !== "round-robin") {
|
|
219
|
+
throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.strategy is unsupported`);
|
|
220
|
+
}
|
|
221
|
+
if (rawVariants.length > 1 && entry.strategy !== "round-robin") {
|
|
222
|
+
throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent} requires strategy round-robin for multiple variants`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const variants: ValidatedStartVariant[] = rawVariants.map((rawVariant, index) => {
|
|
226
|
+
const variant = asRecord(rawVariant);
|
|
227
|
+
if (!variant) throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants[${index}] must be an object`);
|
|
228
|
+
assertAllowedKeys(variant, ["kind", "args"], `agents.${configAgent}.variants[${index}]`);
|
|
229
|
+
const kind = variant.kind;
|
|
230
|
+
if (typeof kind !== "string" || kind.trim() === "") {
|
|
231
|
+
throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants[${index}].kind must be non-empty`);
|
|
232
|
+
}
|
|
233
|
+
const args = variant.args;
|
|
234
|
+
if (hasOwn(variant, "args") && (!Array.isArray(args) || !args.every((arg) => typeof arg === "string"))) {
|
|
235
|
+
throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants[${index}].args must be an array of strings`);
|
|
236
|
+
}
|
|
237
|
+
return { kind, args: Array.isArray(args) ? [...args] : [] };
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
result.set(configAgent, {
|
|
241
|
+
...(hasStrategy ? { strategy: "round-robin" as const } : {}),
|
|
242
|
+
variants,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function loadConfiguredStartAgents(configPath: string): Promise<Map<string, ConfiguredStartAgent>> {
|
|
249
|
+
let text: string;
|
|
250
|
+
try {
|
|
251
|
+
text = await readFile(configPath, "utf8");
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const code = asRecord(error)?.code;
|
|
254
|
+
if (code === "ENOENT") {
|
|
255
|
+
throw startConfigError("START_CONFIG_NOT_FOUND", "agent_config.json was not found");
|
|
256
|
+
}
|
|
257
|
+
throw startConfigError("START_CONFIG_INVALID", "agent_config.json could not be read");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let document: unknown;
|
|
261
|
+
try {
|
|
262
|
+
document = JSON.parse(text) as unknown;
|
|
263
|
+
} catch {
|
|
264
|
+
throw startConfigError("START_CONFIG_INVALID", "agent_config.json is not valid JSON");
|
|
265
|
+
}
|
|
266
|
+
return validateConfiguredDocument(document);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function withStartCursorLock<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
|
270
|
+
const previous = startLocks.get(key) ?? Promise.resolve();
|
|
271
|
+
let release!: () => void;
|
|
272
|
+
const current = new Promise<void>((resolve) => {
|
|
273
|
+
release = resolve;
|
|
274
|
+
});
|
|
275
|
+
startLocks.set(key, current);
|
|
276
|
+
await previous;
|
|
277
|
+
try {
|
|
278
|
+
return await operation();
|
|
279
|
+
} finally {
|
|
280
|
+
release();
|
|
281
|
+
if (startLocks.get(key) === current) startLocks.delete(key);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function runStart(name: string, pane: string, variant: ValidatedStartVariant): Promise<void> {
|
|
286
|
+
try {
|
|
287
|
+
await runHerdr(["agent", "start", name, "--kind", variant.kind, "--pane", pane, "--", ...variant.args]);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// NOT_IN_HERDR is an environment/transport classification; all other
|
|
290
|
+
// Herdr start rejections belong to the start operation. This also
|
|
291
|
+
// prevents the shared agent_not_found mapping from becoming PEER_NOT_FOUND.
|
|
292
|
+
if (error instanceof HerdrLinkError && error.code === "NOT_IN_HERDR") throw error;
|
|
293
|
+
throw operationError(error, "START_FAILED");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface StartAgentOptions {
|
|
298
|
+
/** Runtime context directory used to locate the optional project config. */
|
|
299
|
+
cwd?: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Starts an Agent from a complete configured entry or a complete explicit launch specification. */
|
|
303
|
+
export async function startAgent(input: StartAgentInput, options: StartAgentOptions = {}): Promise<StartAgentReceipt> {
|
|
304
|
+
assertHerdrEnvironment();
|
|
305
|
+
const validated = validateStartInput(input);
|
|
306
|
+
|
|
307
|
+
if (validated.mode === "explicit") {
|
|
308
|
+
await runStart(validated.name, validated.pane, validated.variant);
|
|
309
|
+
return { status: "started", agent: validated.name, kind: validated.variant.kind };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const projectRoot = typeof options.cwd === "string" && options.cwd.trim() !== "" ? options.cwd : process.cwd();
|
|
313
|
+
const configPath = resolve(projectRoot, ...START_CONFIG_PATH_PARTS);
|
|
314
|
+
const cursorKey = `${configPath}\u0000${validated.configAgent}`;
|
|
315
|
+
return withStartCursorLock(cursorKey, async () => {
|
|
316
|
+
const configuredAgents = await loadConfiguredStartAgents(configPath);
|
|
317
|
+
const configured = configuredAgents.get(validated.configAgent);
|
|
318
|
+
if (!configured) {
|
|
319
|
+
throw startConfigError("START_AGENT_NOT_FOUND", `configured Agent "${validated.configAgent}" was not found`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const current = startCursors.get(cursorKey) ?? 0;
|
|
323
|
+
const variantIndex = current % configured.variants.length;
|
|
324
|
+
const variant = configured.variants[variantIndex]!;
|
|
325
|
+
await runStart(validated.name, validated.pane, variant);
|
|
326
|
+
if (configured.variants.length > 1) {
|
|
327
|
+
startCursors.set(cursorKey, (variantIndex + 1) % configured.variants.length);
|
|
328
|
+
}
|
|
329
|
+
return { status: "started", agent: validated.name, kind: variant.kind };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
102
333
|
const CLI_ERROR_CODE_MAP: Record<string, LinkErrorCode> = {
|
|
103
334
|
agent_not_found: "PEER_NOT_FOUND",
|
|
104
335
|
not_in_herdr: "NOT_IN_HERDR",
|
package/src/mcp.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Shared stdio MCP server for Runtimes without a native custom-tool surface
|
|
3
3
|
* (Claude Code, Codex, AGY) — ADR-013/ADR-014.
|
|
4
4
|
*
|
|
5
|
-
* Hand-written, line-delimited JSON-RPC 2.0 over stdin/stdout (
|
|
6
|
-
*
|
|
7
|
-
* herdr.ts control layer. Tool gating and error semantics follow
|
|
8
|
-
* PROTOCOL.md §7; tool-name presentation follows PROTOCOL.md §4.
|
|
5
|
+
* Hand-written, line-delimited JSON-RPC 2.0 over stdin/stdout (no MCP SDK
|
|
6
|
+
* dependency). The published bundle uses only Node built-ins.
|
|
7
|
+
* Tool execution reuses the herdr.ts control layer. Tool gating and error semantics follow
|
|
8
|
+
* PROTOCOL.md §7; tool-name presentation follows PROTOCOL.md §4.6.
|
|
9
9
|
*
|
|
10
10
|
* Lazy presentation (blueprint v2): the tool surface is session-local and
|
|
11
11
|
* dormant until activated. Outside Herdr, `tools/list` is empty. Inside
|
|
@@ -14,28 +14,32 @@
|
|
|
14
14
|
* connection memory), emits `notifications/tools/list_changed`, and from
|
|
15
15
|
* then on `tools/list` additionally offers the canonical Tier 1 tools.
|
|
16
16
|
* Hosts that never refresh can keep dispatching through explicit gateway
|
|
17
|
-
* actions (`{"action":"
|
|
17
|
+
* actions (`{"action":"start","arguments":{...}}`). No daemon, no global
|
|
18
18
|
* state: activation lives and dies with the connection.
|
|
19
19
|
*/
|
|
20
20
|
import { realpathSync } from "node:fs";
|
|
21
21
|
import { pathToFileURL } from "node:url";
|
|
22
22
|
|
|
23
|
-
import { closeAgentPane, ensureSelfName, listPeers, sendMessage } from "./herdr.ts";
|
|
23
|
+
import { closeAgentPane, ensureSelfName, listPeers, sendMessage, startAgent } from "./herdr.ts";
|
|
24
24
|
import {
|
|
25
25
|
COMMUNICATION_CONTRACT,
|
|
26
|
+
HERDR_LINK_COMMUNICATION_TOOLS,
|
|
26
27
|
HERDR_LINK_GATEWAY,
|
|
27
28
|
HERDR_LINK_TOOLS,
|
|
28
29
|
HerdrLinkError,
|
|
30
|
+
START_TOOL_DESCRIPTION,
|
|
29
31
|
TOOL_CLOSE,
|
|
30
32
|
TOOL_PEERS,
|
|
31
33
|
TOOL_SEND,
|
|
34
|
+
TOOL_START,
|
|
32
35
|
formatAgentFacingError,
|
|
33
36
|
type LinkErrorCode,
|
|
37
|
+
type StartAgentInput,
|
|
34
38
|
} from "./protocol.ts";
|
|
35
39
|
|
|
36
40
|
export const MCP_SERVER_NAME = "herdr-link";
|
|
37
41
|
/** Keep in sync with package.json "version" (serverInfo is informational). */
|
|
38
|
-
export const MCP_SERVER_VERSION = "0.
|
|
42
|
+
export const MCP_SERVER_VERSION = "0.4.1";
|
|
39
43
|
/** Fallback protocol version advertised when the client sends none. */
|
|
40
44
|
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
41
45
|
|
|
@@ -47,7 +51,7 @@ export const TOOLS_LIST_CHANGED = "notifications/tools/list_changed";
|
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* JSON-RPC reserved error codes — transport/protocol-level failures only.
|
|
50
|
-
* The
|
|
54
|
+
* The ten Link error codes (PROTOCOL.md §7) are never mapped onto these;
|
|
51
55
|
* they travel inside CallToolResult as isError:true + "CODE: detail" text.
|
|
52
56
|
*/
|
|
53
57
|
export const PARSE_ERROR = -32700;
|
|
@@ -70,10 +74,11 @@ export interface JsonRpcResponse {
|
|
|
70
74
|
*/
|
|
71
75
|
export type NotificationSink = (notification: Record<string, unknown>) => void;
|
|
72
76
|
|
|
73
|
-
type CanonicalToolName = typeof
|
|
77
|
+
type CanonicalToolName = (typeof HERDR_LINK_TOOLS)[number];
|
|
74
78
|
|
|
75
79
|
const NORMAL_MESSAGING_RULE = "Use Herdr Link, not raw Herdr CLI, pane ids, or terminal input, for normal inter-agent messaging.";
|
|
76
80
|
const TOOL_DESCRIPTIONS: Record<CanonicalToolName, string> = {
|
|
81
|
+
[TOOL_START]: `${START_TOOL_DESCRIPTION} ${NORMAL_MESSAGING_RULE}`,
|
|
77
82
|
[TOOL_PEERS]: `Discover live named peers in the same Herdr workspace; each state is advisory and Agent Names are the only addresses. ${NORMAL_MESSAGING_RULE}`,
|
|
78
83
|
[TOOL_SEND]:
|
|
79
84
|
`Send a herdr-link/1 message to a live named peer in your own workspace; status "sent" means Herdr accepted delivery. ${NORMAL_MESSAGING_RULE}`,
|
|
@@ -82,6 +87,21 @@ const TOOL_DESCRIPTIONS: Record<CanonicalToolName, string> = {
|
|
|
82
87
|
};
|
|
83
88
|
|
|
84
89
|
const TOOL_INPUT_SCHEMAS: Record<CanonicalToolName, Record<string, unknown>> = {
|
|
90
|
+
[TOOL_START]: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
name: { type: "string", description: "New Herdr Agent Name" },
|
|
94
|
+
pane: { type: "string", description: "Existing pane id" },
|
|
95
|
+
config_agent: { type: "string", description: "Configured Agent key; do not combine with kind or args" },
|
|
96
|
+
kind: { type: "string", description: "Herdr Agent kind for explicit start" },
|
|
97
|
+
args: { type: "array", items: { type: "string" }, description: "Complete Herdr Agent arguments for explicit start" },
|
|
98
|
+
},
|
|
99
|
+
required: ["name", "pane"],
|
|
100
|
+
oneOf: [
|
|
101
|
+
{ required: ["config_agent"], not: { anyOf: [{ required: ["kind"] }, { required: ["args"] }] } },
|
|
102
|
+
{ required: ["kind", "args"], not: { required: ["config_agent"] } },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
85
105
|
[TOOL_PEERS]: { type: "object", properties: {} },
|
|
86
106
|
[TOOL_SEND]: {
|
|
87
107
|
type: "object",
|
|
@@ -102,6 +122,7 @@ const TOOL_INPUT_SCHEMAS: Record<CanonicalToolName, Record<string, unknown>> = {
|
|
|
102
122
|
|
|
103
123
|
/** Unexpected exceptions fall back to the operation's own failure code so the §7 vocabulary stays closed. */
|
|
104
124
|
const FALLBACK_ERROR_CODE: Record<CanonicalToolName, LinkErrorCode> = {
|
|
125
|
+
[TOOL_START]: "START_FAILED",
|
|
105
126
|
[TOOL_PEERS]: "NOT_IN_HERDR",
|
|
106
127
|
[TOOL_SEND]: "SEND_FAILED",
|
|
107
128
|
[TOOL_CLOSE]: "CLOSE_FAILED",
|
|
@@ -115,10 +136,9 @@ const FALLBACK_ERROR_CODE: Record<CanonicalToolName, LinkErrorCode> = {
|
|
|
115
136
|
const GATEWAY_TOOL: { name: typeof HERDR_LINK_GATEWAY; description: string; inputSchema: Record<string, unknown> } = {
|
|
116
137
|
name: HERDR_LINK_GATEWAY,
|
|
117
138
|
description:
|
|
118
|
-
"Herdr Link gateway. Activate only when the user explicitly asks to use Herdr or when handling an inbound Herdr Link message. Cross-agent
|
|
119
|
-
"({}) to activate it for this session — the host is notified via notifications/tools/list_changed " +
|
|
120
|
-
|
|
121
|
-
'If your host did not refresh its tool list, keep dispatching through the gateway: {"action":"peers"}, ' +
|
|
139
|
+
"Herdr Link gateway. Activate only when the user explicitly asks to use Herdr or when handling an inbound Herdr Link message. Cross-agent control starts dormant: call this tool once with no arguments " +
|
|
140
|
+
"({}) to activate it for this session — the host is notified via notifications/tools/list_changed and herdr_link_start / herdr_link_peers / herdr_link_send / herdr_link_close become available as regular tools. " +
|
|
141
|
+
'If your host did not refresh its tool list, keep dispatching through the gateway: {"action":"start","arguments":{...}}, {"action":"peers"}, ' +
|
|
122
142
|
'{"action":"send","arguments":{"to":...,"message":...}}, or ' +
|
|
123
143
|
'{"action":"close","arguments":{"agent":...}}.',
|
|
124
144
|
inputSchema: {
|
|
@@ -126,9 +146,9 @@ const GATEWAY_TOOL: { name: typeof HERDR_LINK_GATEWAY; description: string; inpu
|
|
|
126
146
|
properties: {
|
|
127
147
|
action: {
|
|
128
148
|
type: "string",
|
|
129
|
-
enum: ["activate", "peers", "send", "close"],
|
|
149
|
+
enum: ["activate", "start", "peers", "send", "close"],
|
|
130
150
|
description:
|
|
131
|
-
'Omit or use "activate" to turn the session on; other values dispatch the corresponding peers, send, or close capability.',
|
|
151
|
+
'Omit or use "activate" to turn the session on; other values dispatch the corresponding start, peers, send, or close capability.',
|
|
132
152
|
},
|
|
133
153
|
arguments: {
|
|
134
154
|
type: "object",
|
|
@@ -230,6 +250,7 @@ export interface McpServerDeps {
|
|
|
230
250
|
listPeers?: typeof listPeers;
|
|
231
251
|
sendMessage?: typeof sendMessage;
|
|
232
252
|
closeAgentPane?: typeof closeAgentPane;
|
|
253
|
+
startAgent?: typeof startAgent;
|
|
233
254
|
/**
|
|
234
255
|
* Receives server-to-host notifications (currently only
|
|
235
256
|
* `notifications/tools/list_changed`). Defaults to stdout.
|
|
@@ -250,6 +271,7 @@ export function createRequestHandler(
|
|
|
250
271
|
const runPeers = deps.listPeers ?? listPeers;
|
|
251
272
|
const runSend = deps.sendMessage ?? sendMessage;
|
|
252
273
|
const runClose = deps.closeAgentPane ?? closeAgentPane;
|
|
274
|
+
const runStart = deps.startAgent ?? startAgent;
|
|
253
275
|
const notify = deps.notify ?? stdoutNotificationSink;
|
|
254
276
|
|
|
255
277
|
/** Session-local lazy activation (blueprint v2). True ⇒ Tier 1 tools are listed. */
|
|
@@ -295,6 +317,8 @@ export function createRequestHandler(
|
|
|
295
317
|
args: Record<string, unknown>,
|
|
296
318
|
): Promise<object> {
|
|
297
319
|
switch (canonicalName) {
|
|
320
|
+
case TOOL_START:
|
|
321
|
+
return await runStart(args as unknown as StartAgentInput);
|
|
298
322
|
case TOOL_PEERS:
|
|
299
323
|
return await runPeers();
|
|
300
324
|
case TOOL_SEND: {
|
|
@@ -338,22 +362,24 @@ export function createRequestHandler(
|
|
|
338
362
|
activateSession();
|
|
339
363
|
return callSuccess(id, {
|
|
340
364
|
status: "active",
|
|
341
|
-
capabilities: ["peers", "send", "close"],
|
|
365
|
+
capabilities: ["start", "peers", "send", "close"],
|
|
342
366
|
});
|
|
343
367
|
}
|
|
344
368
|
if (
|
|
345
369
|
typeof action !== "string" ||
|
|
346
|
-
!(["peers", "send", "close"] as readonly string[]).includes(action)
|
|
370
|
+
!(["start", "peers", "send", "close"] as readonly string[]).includes(action)
|
|
347
371
|
) {
|
|
348
372
|
return fail(id, INVALID_PARAMS, `Unknown gateway action: ${String(action)}`);
|
|
349
373
|
}
|
|
350
374
|
const canonicalName = (
|
|
351
|
-
action === "peers" ? TOOL_PEERS : action === "send" ? TOOL_SEND : TOOL_CLOSE
|
|
375
|
+
action === "start" ? TOOL_START : action === "peers" ? TOOL_PEERS : action === "send" ? TOOL_SEND : TOOL_CLOSE
|
|
352
376
|
) as CanonicalToolName;
|
|
353
377
|
activateSession();
|
|
354
378
|
// Prefer the nested canonical arguments object; otherwise accept the
|
|
355
379
|
// remaining top-level fields directly (deterministic either way).
|
|
356
|
-
const dispatchArgs = isRecord(args.arguments)
|
|
380
|
+
const dispatchArgs = isRecord(args.arguments)
|
|
381
|
+
? args.arguments
|
|
382
|
+
: Object.fromEntries(Object.entries(args).filter(([key]) => key !== "action"));
|
|
357
383
|
return await callCanonicalTool(id, canonicalName, dispatchArgs);
|
|
358
384
|
}
|
|
359
385
|
|
|
@@ -479,9 +505,8 @@ export async function runStdioServer(
|
|
|
479
505
|
}
|
|
480
506
|
|
|
481
507
|
/**
|
|
482
|
-
* Host-facing presented name for a canonical tool on prefix-style hosts (PROTOCOL.md
|
|
483
|
-
*
|
|
484
|
-
* host-specific ("herdr_link" for the Codex wiring) and deliberately NOT
|
|
508
|
+
* Host-facing presented name for a canonical tool on prefix-style hosts (PROTOCOL.md §4.6): the full canonical name is always the suffix. The namespace is
|
|
509
|
+
* host-specific (`herdr_link` for the Codex wiring) and deliberately NOT
|
|
485
510
|
* defaulted — serverInfo.name and the host tool namespace are different
|
|
486
511
|
* concerns, so callers must state explicitly which namespace a contract declares.
|
|
487
512
|
*/
|
|
@@ -504,15 +529,18 @@ function contractWithAppendix(appendix: string): string {
|
|
|
504
529
|
* gateway is listed until the model activates it (blueprint v2).
|
|
505
530
|
*/
|
|
506
531
|
export function buildMcpPrefixedCommunicationContract(namespace: string): string {
|
|
507
|
-
const [peers, send, close] =
|
|
532
|
+
const [peers, send, close] = HERDR_LINK_COMMUNICATION_TOOLS.map((name) =>
|
|
508
533
|
mcpPresentedToolName(name, namespace),
|
|
509
534
|
);
|
|
535
|
+
const start = mcpPresentedToolName(TOOL_START, namespace);
|
|
510
536
|
const gateway = mcpPresentedToolName(HERDR_LINK_GATEWAY, namespace);
|
|
511
537
|
return contractWithAppendix(
|
|
512
538
|
`In this runtime Herdr Link starts dormant: only the ${gateway} gateway tool is listed until it is activated.\n` +
|
|
513
539
|
`- Call ${gateway} once with no arguments ({}); the host then receives notifications/tools/list_changed and the cross-agent tools become available.\n` +
|
|
514
|
-
`- If the host did not refresh its tool list, keep dispatching through the gateway: {"action":"peers"}, {"action":"send","arguments":{...}}, {"action":"close","arguments":{...}}.\n` +
|
|
540
|
+
`- If the host did not refresh its tool list, keep dispatching through the gateway: {"action":"start","arguments":{...}}, {"action":"peers"}, {"action":"send","arguments":{...}}, {"action":"close","arguments":{...}}.\n` +
|
|
541
|
+
`- ${START_TOOL_DESCRIPTION}\n` +
|
|
515
542
|
`The tools are presented under MCP-prefixed names (the canonical name is always the suffix):\n` +
|
|
543
|
+
`- herdr_link_start -> ${start}\n` +
|
|
516
544
|
`- herdr_link_peers -> ${peers}\n` +
|
|
517
545
|
`- herdr_link_send -> ${send}\n` +
|
|
518
546
|
`- herdr_link_close -> ${close}`,
|
|
@@ -522,7 +550,7 @@ export function buildMcpPrefixedCommunicationContract(namespace: string): string
|
|
|
522
550
|
/**
|
|
523
551
|
* Contract text for wrapper-style MCP hosts (e.g. AGY's call_mcp_tool): the
|
|
524
552
|
* model invokes one native wrapper carrying ServerName/ToolName/Arguments
|
|
525
|
-
* instead of per-tool functions (PROTOCOL.md §4.
|
|
553
|
+
* instead of per-tool functions (PROTOCOL.md §4.6 wrapper form). Both values
|
|
526
554
|
* must be explicit. Presentation is lazy (blueprint v2): activate the gateway
|
|
527
555
|
* first, then address the canonical tools through the same wrapper.
|
|
528
556
|
*/
|
|
@@ -533,11 +561,12 @@ export function buildMcpWrapperCommunicationContract(
|
|
|
533
561
|
return contractWithAppendix(
|
|
534
562
|
`In this runtime Herdr Link starts dormant: only the Tier 0 gateway (${HERDR_LINK_GATEWAY}) is listed until it is activated.\n` +
|
|
535
563
|
`- Invoke the gateway once with empty Arguments {} (ToolName "${HERDR_LINK_GATEWAY}"); the host then receives notifications/tools/list_changed and the cross-agent tools become available.\n` +
|
|
536
|
-
`- If the host did not refresh its tool list, keep dispatching through the gateway with ToolName "${HERDR_LINK_GATEWAY}" and an Arguments object carrying {"action":"peers"|"send"|"close", ...}.\n
|
|
564
|
+
`- If the host did not refresh its tool list, keep dispatching through the gateway with ToolName "${HERDR_LINK_GATEWAY}" and an Arguments object carrying {"action":"start"|"peers"|"send"|"close", ...}.\n` +
|
|
565
|
+
`- ${START_TOOL_DESCRIPTION}\n\n` +
|
|
537
566
|
`After activation, Herdr Link MCP tools are invoked through ${wrapperName}.\n\n` +
|
|
538
567
|
`Use:\n` +
|
|
539
568
|
`- ServerName: "${serverName}"\n` +
|
|
540
|
-
`- ToolName: "herdr_link_peers", "herdr_link_send", or "herdr_link_close"\n` +
|
|
569
|
+
`- ToolName: "herdr_link_start", "herdr_link_peers", "herdr_link_send", or "herdr_link_close"\n` +
|
|
541
570
|
`- Arguments: the canonical input object for that Herdr Link tool`,
|
|
542
571
|
);
|
|
543
572
|
}
|
package/src/opencode.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* The model-facing surface is exactly one tiny `herdr_link` dispatcher tool,
|
|
5
5
|
* in both dormant and active states. Calling it with no arguments (`{}`)
|
|
6
6
|
* idempotently activates the channel for the CURRENT session and returns
|
|
7
|
-
* `{ status: "active", capabilities: ["peers", "send", "close"] }`; while a
|
|
7
|
+
* `{ status: "active", capabilities: ["start", "peers", "send", "close"] }`; while a
|
|
8
8
|
* session is active the same gateway executes deterministic actions
|
|
9
|
-
* (`action`: "peers" | "send" | "close") against the core control layer and
|
|
9
|
+
* (`action`: "start" | "peers" | "send" | "close") against the core control layer and
|
|
10
10
|
* the compact Communication Contract is injected into that session's system
|
|
11
|
-
* prompt. The
|
|
11
|
+
* prompt. The Tier 1 capabilities are never registered as always-resident
|
|
12
12
|
* surfaces.
|
|
13
13
|
*
|
|
14
14
|
* API basis (public @opencode-ai/plugin 1.18.x only):
|
|
@@ -30,17 +30,20 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { tool, type Plugin } from "@opencode-ai/plugin";
|
|
32
32
|
|
|
33
|
-
import { closeAgentPane, ensureSelfName, listPeers, sendMessage } from "./herdr.ts";
|
|
33
|
+
import { closeAgentPane, ensureSelfName, listPeers, sendMessage, startAgent } from "./herdr.ts";
|
|
34
34
|
import {
|
|
35
35
|
COMMUNICATION_CONTRACT,
|
|
36
36
|
HERDR_LINK_GATEWAY,
|
|
37
37
|
HerdrLinkError,
|
|
38
|
+
START_TOOL_DESCRIPTION,
|
|
38
39
|
formatAgentFacingError,
|
|
39
40
|
type LinkErrorCode,
|
|
41
|
+
type StartAgentInput,
|
|
40
42
|
} from "./protocol.ts";
|
|
41
43
|
|
|
42
44
|
/** Runtime-specific active presentation; the semantic Contract remains canonical. */
|
|
43
45
|
const GATEWAY_PRESENTATION_APPENDIX = `In this runtime the active Herdr Link capabilities are dispatched through the single herdr_link gateway.
|
|
46
|
+
- Use herdr_link with action "start": ${START_TOOL_DESCRIPTION}
|
|
44
47
|
- Use herdr_link with action "peers" to list live same-workspace agents.
|
|
45
48
|
- Use herdr_link with action "send" with to and message to deliver an inter-agent message or ordinary reply.
|
|
46
49
|
- Use herdr_link with action "close" and an Agent Name only after any final send returns status "sent", in a later tool step.`;
|
|
@@ -67,7 +70,7 @@ function failWith(error: unknown, fallbackCode: LinkErrorCode): never {
|
|
|
67
70
|
/** Dispatcher-level usage guard for actions rejected by the schema anyway. */
|
|
68
71
|
function failInvalidAction(action: string): never {
|
|
69
72
|
throw new Error(
|
|
70
|
-
`INVALID_ACTION: herdr_link action "${action}" is not supported; use "peers", "send", "close", or omit action (call with {}) to activate.`,
|
|
73
|
+
`INVALID_ACTION: herdr_link action "${action}" is not supported; use "start", "peers", "send", "close", or omit action (call with {}) to activate.`,
|
|
71
74
|
);
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -90,16 +93,16 @@ export const herdrLinkPlugin: Plugin = async () => {
|
|
|
90
93
|
tool: {
|
|
91
94
|
[HERDR_LINK_GATEWAY]: tool({
|
|
92
95
|
description:
|
|
93
|
-
"Herdr Link cross-agent
|
|
96
|
+
"Herdr Link cross-agent control gateway (herdr-link/1). Activate only when the user explicitly asks to use Herdr or when handling an inbound Herdr Link message. " +
|
|
94
97
|
'Call once with no arguments {} to activate Herdr Link for this session; the response lists capabilities. ' +
|
|
95
|
-
'Then pass action "peers" to list live same-workspace agents, "send" with to + message to deliver an inter-agent message or ordinary reply, or "close" with agent to close a named agent\'s pane — ' +
|
|
96
|
-
'only after any final send has returned status "sent",
|
|
98
|
+
'Then pass action "start" with name + pane and either config_agent or complete kind + args, action "peers" to list live same-workspace agents, action "send" with to + message to deliver an inter-agent message or ordinary reply, or action "close" with agent to close a named agent\'s pane — ' +
|
|
99
|
+
'start modes are mutually exclusive and close is only after any final send has returned status "sent", in a later tool step.',
|
|
97
100
|
args: {
|
|
98
101
|
action: tool.schema
|
|
99
|
-
.enum(["peers", "send", "close"])
|
|
102
|
+
.enum(["start", "peers", "send", "close"])
|
|
100
103
|
.optional()
|
|
101
104
|
.describe(
|
|
102
|
-
'Operation to run: "peers"
|
|
105
|
+
'Operation to run: "start", "peers", "send", or "close". Omit action entirely (call with {}) to activate Herdr Link for this session.',
|
|
103
106
|
),
|
|
104
107
|
to: tool.schema
|
|
105
108
|
.string()
|
|
@@ -113,16 +116,45 @@ export const herdrLinkPlugin: Plugin = async () => {
|
|
|
113
116
|
.string()
|
|
114
117
|
.optional()
|
|
115
118
|
.describe('Target agent name; required for action "close".'),
|
|
119
|
+
name: tool.schema
|
|
120
|
+
.string()
|
|
121
|
+
.optional()
|
|
122
|
+
.describe('New Agent Name; required for action "start".'),
|
|
123
|
+
pane: tool.schema
|
|
124
|
+
.string()
|
|
125
|
+
.optional()
|
|
126
|
+
.describe('Existing pane id; required for action "start".'),
|
|
127
|
+
config_agent: tool.schema
|
|
128
|
+
.string()
|
|
129
|
+
.optional()
|
|
130
|
+
.describe('Configured Agent key for action "start"; do not combine with kind or args.'),
|
|
131
|
+
kind: tool.schema
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe('Herdr Agent kind for explicit action "start".'),
|
|
135
|
+
args: tool.schema
|
|
136
|
+
.array(tool.schema.string())
|
|
137
|
+
.optional()
|
|
138
|
+
.describe('Complete Herdr Agent arguments for explicit action "start".'),
|
|
116
139
|
},
|
|
117
140
|
async execute(args, context) {
|
|
118
141
|
if (args.action === undefined) {
|
|
119
142
|
activatedSessions.add(context.sessionID);
|
|
120
|
-
return jsonResult({ status: "active", capabilities: ["peers", "send", "close"] });
|
|
143
|
+
return jsonResult({ status: "active", capabilities: ["start", "peers", "send", "close"] });
|
|
121
144
|
}
|
|
122
145
|
// Gateway action dispatch is also an explicit activation path for
|
|
123
146
|
// hosts that bypass the empty gateway call or do not refresh schemas.
|
|
124
147
|
activatedSessions.add(context.sessionID);
|
|
125
148
|
|
|
149
|
+
if (args.action === "start") {
|
|
150
|
+
const startInput = Object.fromEntries(Object.entries(args).filter(([key]) => key !== "action"));
|
|
151
|
+
try {
|
|
152
|
+
return jsonResult(await startAgent(startInput as unknown as StartAgentInput, { cwd: context.directory }));
|
|
153
|
+
} catch (error) {
|
|
154
|
+
failWith(error, "START_FAILED");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
126
158
|
if (args.action === "peers") {
|
|
127
159
|
try {
|
|
128
160
|
return jsonResult(await listPeers());
|