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/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 (zero
6
- * dependencies, no @modelcontextprotocol/sdk). Tool execution reuses the
7
- * herdr.ts control layer. Tool gating and error semantics follow
8
- * PROTOCOL.md §7; tool-name presentation follows PROTOCOL.md §4.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":"send","arguments":{...}}`). No daemon, no global
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.2.1";
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 five Link error codes (PROTOCOL.md §7) are never mapped onto these;
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 TOOL_PEERS | typeof TOOL_SEND | typeof TOOL_CLOSE;
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 messaging starts dormant: call this tool once with no arguments " +
119
- "({}) to activate it for this session — the host is notified via notifications/tools/list_changed " +
120
- "and herdr_link_peers / herdr_link_send / herdr_link_close become available as regular tools. " +
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) ? args.arguments : args;
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
- * §4.4): the full canonical name is always the suffix. The namespace is
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] = HERDR_LINK_TOOLS.map((name) =>
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.4 wrapper form). Both values
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\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 three Tier 1 tools are never registered as always-resident
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 communication gateway (herdr-link/1). Activate only when the user explicitly asks to use Herdr or when handling an inbound Herdr Link message. " +
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", and in a later tool step.',
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" | "send" | "close". Omit action entirely (call with {}) to activate Herdr Link for this session.',
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());