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.
@@ -6,16 +6,20 @@ import { pathToFileURL } from "node:url";
6
6
 
7
7
  // src/herdr.ts
8
8
  import { execFile } from "node:child_process";
9
+ import { readFile } from "node:fs/promises";
9
10
  import { randomBytes } from "node:crypto";
11
+ import { resolve } from "node:path";
10
12
 
11
13
  // src/protocol.ts
12
14
  var PROTOCOL_ID = "herdr-link/1";
13
15
  var AGENT_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
14
16
  var HERDR_LINK_GATEWAY = "herdr_link";
17
+ var TOOL_START = "herdr_link_start";
15
18
  var TOOL_PEERS = "herdr_link_peers";
16
19
  var TOOL_SEND = "herdr_link_send";
17
20
  var TOOL_CLOSE = "herdr_link_close";
18
- var HERDR_LINK_TOOLS = [TOOL_PEERS, TOOL_SEND, TOOL_CLOSE];
21
+ var HERDR_LINK_TOOLS = [TOOL_START, TOOL_PEERS, TOOL_SEND, TOOL_CLOSE];
22
+ var HERDR_LINK_COMMUNICATION_TOOLS = [TOOL_PEERS, TOOL_SEND, TOOL_CLOSE];
19
23
  var AGENT_STATES = ["idle", "working", "blocked", "done", "unknown"];
20
24
  function toAgentState(value) {
21
25
  if (typeof value === "string") {
@@ -26,6 +30,7 @@ function toAgentState(value) {
26
30
  }
27
31
  return "unknown";
28
32
  }
33
+ var START_TOOL_DESCRIPTION = "Start a new Herdr Agent in an existing pane. Provide name and pane, then choose exactly one complete parameter source: config_agent for .agents/agent_config.json, or kind plus args for explicit Herdr start parameters. These modes are mutually exclusive; partial overrides are not supported. This operation does not create panes or retry/fallback after failure.";
29
34
  var HerdrLinkError = class extends Error {
30
35
  code;
31
36
  constructor(code, detail) {
@@ -39,7 +44,12 @@ var AGENT_ERROR_DETAILS = {
39
44
  SELF_UNNAMED: "Herdr Link could not establish a stable Agent Name",
40
45
  PEER_NOT_FOUND: "target agent is not a live peer",
41
46
  SEND_FAILED: "Herdr did not accept message delivery",
42
- CLOSE_FAILED: "Herdr pane close failed"
47
+ CLOSE_FAILED: "Herdr pane close failed",
48
+ START_CONFIG_NOT_FOUND: "configured Agent start configuration was not found",
49
+ START_AGENT_NOT_FOUND: "configured Agent start entry was not found",
50
+ START_CONFIG_INVALID: "configured Agent start configuration is invalid",
51
+ START_INPUT_INVALID: "Agent start input is invalid",
52
+ START_FAILED: "Herdr did not accept Agent start"
43
53
  };
44
54
  function formatAgentFacingError(error, fallbackCode) {
45
55
  const code = error instanceof HerdrLinkError ? error.code : fallbackCode;
@@ -111,14 +121,14 @@ var COMMUNICATION_CONTRACT = `Herdr Link is the standard interoperability channe
111
121
  function attachCliOutput(error, stdout, stderr) {
112
122
  Object.assign(error, { stdout, stderr });
113
123
  }
114
- var defaultHerdrRunner = (file, args) => new Promise((resolve, reject) => {
124
+ var defaultHerdrRunner = (file, args) => new Promise((resolve2, reject) => {
115
125
  execFile(file, args, { encoding: "utf8", shell: false }, (error, stdout, stderr) => {
116
126
  if (error) {
117
127
  attachCliOutput(error, String(stdout), String(stderr));
118
128
  reject(error);
119
129
  return;
120
130
  }
121
- resolve({ stdout: String(stdout), stderr: String(stderr) });
131
+ resolve2({ stdout: String(stdout), stderr: String(stderr) });
122
132
  });
123
133
  });
124
134
  var herdrRunner = defaultHerdrRunner;
@@ -167,6 +177,180 @@ async function runFor(args, failureCode) {
167
177
  throw operationError(error, failureCode);
168
178
  }
169
179
  }
180
+ var startCursors = /* @__PURE__ */ new Map();
181
+ var startLocks = /* @__PURE__ */ new Map();
182
+ var START_CONFIG_PATH_PARTS = [".agents", "agent_config.json"];
183
+ var START_INPUT_KEYS = /* @__PURE__ */ new Set(["name", "pane", "config_agent", "kind", "args"]);
184
+ function hasOwn(value, key) {
185
+ return Object.prototype.hasOwnProperty.call(value, key);
186
+ }
187
+ function startInputError(detail) {
188
+ return new HerdrLinkError("START_INPUT_INVALID", detail);
189
+ }
190
+ function startConfigError(code, detail) {
191
+ return new HerdrLinkError(code, detail);
192
+ }
193
+ function validateStartInput(input) {
194
+ const value = asRecord(input);
195
+ if (!value) throw startInputError("start input must be an object");
196
+ for (const key of Object.keys(value)) {
197
+ if (!START_INPUT_KEYS.has(key)) throw startInputError(`unknown start field "${key}"`);
198
+ }
199
+ const name = value.name;
200
+ if (typeof name !== "string" || !isValidAgentName(name)) {
201
+ throw startInputError('"name" must be a valid Herdr Agent Name');
202
+ }
203
+ const pane = value.pane;
204
+ if (typeof pane !== "string" || pane.trim() === "") {
205
+ throw startInputError('"pane" must be a non-empty pane id');
206
+ }
207
+ const hasConfigAgent = hasOwn(value, "config_agent");
208
+ const hasKind = hasOwn(value, "kind");
209
+ const hasArgs = hasOwn(value, "args");
210
+ if (hasConfigAgent && (hasKind || hasArgs)) {
211
+ throw startInputError("config_agent cannot be combined with kind or args");
212
+ }
213
+ if (hasConfigAgent) {
214
+ const configAgent = value.config_agent;
215
+ if (typeof configAgent !== "string" || configAgent.trim() === "") {
216
+ throw startInputError('"config_agent" must be a non-empty string');
217
+ }
218
+ return { mode: "configured", name, pane, configAgent };
219
+ }
220
+ if (!hasKind || !hasArgs) {
221
+ throw startInputError("explicit start requires both kind and args");
222
+ }
223
+ const kind = value.kind;
224
+ if (typeof kind !== "string" || kind.trim() === "") {
225
+ throw startInputError('"kind" must be a non-empty string');
226
+ }
227
+ const args = value.args;
228
+ if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) {
229
+ throw startInputError('"args" must be an array of strings');
230
+ }
231
+ return { mode: "explicit", name, pane, variant: { kind, args: [...args] } };
232
+ }
233
+ function assertAllowedKeys(value, allowed, label) {
234
+ const allowedSet = new Set(allowed);
235
+ for (const key of Object.keys(value)) {
236
+ if (!allowedSet.has(key)) throw startConfigError("START_CONFIG_INVALID", `${label} contains unknown field "${key}"`);
237
+ }
238
+ }
239
+ function validateConfiguredDocument(document) {
240
+ const root = asRecord(document);
241
+ if (!root) throw startConfigError("START_CONFIG_INVALID", "configuration root must be an object");
242
+ assertAllowedKeys(root, ["version", "agents"], "configuration root");
243
+ if (root.version !== 1) throw startConfigError("START_CONFIG_INVALID", "configuration version must be 1");
244
+ const agents = asRecord(root.agents);
245
+ if (!agents) throw startConfigError("START_CONFIG_INVALID", "agents must be an object");
246
+ const result = /* @__PURE__ */ new Map();
247
+ for (const [configAgent, rawEntry] of Object.entries(agents)) {
248
+ if (configAgent.trim() === "") {
249
+ throw startConfigError("START_CONFIG_INVALID", "agents contains an empty configuration key");
250
+ }
251
+ const entry = asRecord(rawEntry);
252
+ if (!entry) throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent} must be an object`);
253
+ assertAllowedKeys(entry, ["strategy", "variants"], `agents.${configAgent}`);
254
+ const rawVariants = entry.variants;
255
+ if (!Array.isArray(rawVariants) || rawVariants.length === 0) {
256
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants must be non-empty`);
257
+ }
258
+ const hasStrategy = hasOwn(entry, "strategy");
259
+ if (hasStrategy && entry.strategy !== "round-robin") {
260
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.strategy is unsupported`);
261
+ }
262
+ if (rawVariants.length > 1 && entry.strategy !== "round-robin") {
263
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent} requires strategy round-robin for multiple variants`);
264
+ }
265
+ const variants = rawVariants.map((rawVariant, index) => {
266
+ const variant = asRecord(rawVariant);
267
+ if (!variant) throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants[${index}] must be an object`);
268
+ assertAllowedKeys(variant, ["kind", "args"], `agents.${configAgent}.variants[${index}]`);
269
+ const kind = variant.kind;
270
+ if (typeof kind !== "string" || kind.trim() === "") {
271
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants[${index}].kind must be non-empty`);
272
+ }
273
+ const args = variant.args;
274
+ if (hasOwn(variant, "args") && (!Array.isArray(args) || !args.every((arg) => typeof arg === "string"))) {
275
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants[${index}].args must be an array of strings`);
276
+ }
277
+ return { kind, args: Array.isArray(args) ? [...args] : [] };
278
+ });
279
+ result.set(configAgent, {
280
+ ...hasStrategy ? { strategy: "round-robin" } : {},
281
+ variants
282
+ });
283
+ }
284
+ return result;
285
+ }
286
+ async function loadConfiguredStartAgents(configPath) {
287
+ let text;
288
+ try {
289
+ text = await readFile(configPath, "utf8");
290
+ } catch (error) {
291
+ const code = asRecord(error)?.code;
292
+ if (code === "ENOENT") {
293
+ throw startConfigError("START_CONFIG_NOT_FOUND", "agent_config.json was not found");
294
+ }
295
+ throw startConfigError("START_CONFIG_INVALID", "agent_config.json could not be read");
296
+ }
297
+ let document;
298
+ try {
299
+ document = JSON.parse(text);
300
+ } catch {
301
+ throw startConfigError("START_CONFIG_INVALID", "agent_config.json is not valid JSON");
302
+ }
303
+ return validateConfiguredDocument(document);
304
+ }
305
+ async function withStartCursorLock(key, operation) {
306
+ const previous = startLocks.get(key) ?? Promise.resolve();
307
+ let release;
308
+ const current = new Promise((resolve2) => {
309
+ release = resolve2;
310
+ });
311
+ startLocks.set(key, current);
312
+ await previous;
313
+ try {
314
+ return await operation();
315
+ } finally {
316
+ release();
317
+ if (startLocks.get(key) === current) startLocks.delete(key);
318
+ }
319
+ }
320
+ async function runStart(name, pane, variant) {
321
+ try {
322
+ await runHerdr(["agent", "start", name, "--kind", variant.kind, "--pane", pane, "--", ...variant.args]);
323
+ } catch (error) {
324
+ if (error instanceof HerdrLinkError && error.code === "NOT_IN_HERDR") throw error;
325
+ throw operationError(error, "START_FAILED");
326
+ }
327
+ }
328
+ async function startAgent(input, options = {}) {
329
+ assertHerdrEnvironment();
330
+ const validated = validateStartInput(input);
331
+ if (validated.mode === "explicit") {
332
+ await runStart(validated.name, validated.pane, validated.variant);
333
+ return { status: "started", agent: validated.name, kind: validated.variant.kind };
334
+ }
335
+ const projectRoot = typeof options.cwd === "string" && options.cwd.trim() !== "" ? options.cwd : process.cwd();
336
+ const configPath = resolve(projectRoot, ...START_CONFIG_PATH_PARTS);
337
+ const cursorKey = `${configPath}\0${validated.configAgent}`;
338
+ return withStartCursorLock(cursorKey, async () => {
339
+ const configuredAgents = await loadConfiguredStartAgents(configPath);
340
+ const configured = configuredAgents.get(validated.configAgent);
341
+ if (!configured) {
342
+ throw startConfigError("START_AGENT_NOT_FOUND", `configured Agent "${validated.configAgent}" was not found`);
343
+ }
344
+ const current = startCursors.get(cursorKey) ?? 0;
345
+ const variantIndex = current % configured.variants.length;
346
+ const variant = configured.variants[variantIndex];
347
+ await runStart(validated.name, validated.pane, variant);
348
+ if (configured.variants.length > 1) {
349
+ startCursors.set(cursorKey, (variantIndex + 1) % configured.variants.length);
350
+ }
351
+ return { status: "started", agent: validated.name, kind: variant.kind };
352
+ });
353
+ }
170
354
  var CLI_ERROR_CODE_MAP = {
171
355
  agent_not_found: "PEER_NOT_FOUND",
172
356
  not_in_herdr: "NOT_IN_HERDR"
@@ -266,7 +450,7 @@ function stableName(record) {
266
450
  }
267
451
  var SELF_PROBE_ATTEMPTS = 3;
268
452
  var SELF_PROBE_DELAY_MS = 100;
269
- var sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
453
+ var sleepMs = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
270
454
  async function fetchSelfRecord(pane) {
271
455
  for (let attempt = 1; ; attempt += 1) {
272
456
  try {
@@ -429,7 +613,7 @@ async function closeAgentPane(agentName) {
429
613
 
430
614
  // src/mcp.ts
431
615
  var MCP_SERVER_NAME = "herdr-link";
432
- var MCP_SERVER_VERSION = "0.2.1";
616
+ var MCP_SERVER_VERSION = "0.4.1";
433
617
  var MCP_PROTOCOL_VERSION = "2025-06-18";
434
618
  var TOOLS_LIST_CHANGED = "notifications/tools/list_changed";
435
619
  var PARSE_ERROR = -32700;
@@ -438,11 +622,27 @@ var METHOD_NOT_FOUND = -32601;
438
622
  var INVALID_PARAMS = -32602;
439
623
  var NORMAL_MESSAGING_RULE = "Use Herdr Link, not raw Herdr CLI, pane ids, or terminal input, for normal inter-agent messaging.";
440
624
  var TOOL_DESCRIPTIONS = {
625
+ [TOOL_START]: `${START_TOOL_DESCRIPTION} ${NORMAL_MESSAGING_RULE}`,
441
626
  [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}`,
442
627
  [TOOL_SEND]: `Send a herdr-link/1 message to a live named peer in your own workspace; status "sent" means Herdr accepted delivery. ${NORMAL_MESSAGING_RULE}`,
443
628
  [TOOL_CLOSE]: `Close the pane currently hosting a named same-workspace agent. If you need to send a final message before closing, complete the send first and call close in a later tool step. ${NORMAL_MESSAGING_RULE}`
444
629
  };
445
630
  var TOOL_INPUT_SCHEMAS = {
631
+ [TOOL_START]: {
632
+ type: "object",
633
+ properties: {
634
+ name: { type: "string", description: "New Herdr Agent Name" },
635
+ pane: { type: "string", description: "Existing pane id" },
636
+ config_agent: { type: "string", description: "Configured Agent key; do not combine with kind or args" },
637
+ kind: { type: "string", description: "Herdr Agent kind for explicit start" },
638
+ args: { type: "array", items: { type: "string" }, description: "Complete Herdr Agent arguments for explicit start" }
639
+ },
640
+ required: ["name", "pane"],
641
+ oneOf: [
642
+ { required: ["config_agent"], not: { anyOf: [{ required: ["kind"] }, { required: ["args"] }] } },
643
+ { required: ["kind", "args"], not: { required: ["config_agent"] } }
644
+ ]
645
+ },
446
646
  [TOOL_PEERS]: { type: "object", properties: {} },
447
647
  [TOOL_SEND]: {
448
648
  type: "object",
@@ -461,20 +661,21 @@ var TOOL_INPUT_SCHEMAS = {
461
661
  }
462
662
  };
463
663
  var FALLBACK_ERROR_CODE = {
664
+ [TOOL_START]: "START_FAILED",
464
665
  [TOOL_PEERS]: "NOT_IN_HERDR",
465
666
  [TOOL_SEND]: "SEND_FAILED",
466
667
  [TOOL_CLOSE]: "CLOSE_FAILED"
467
668
  };
468
669
  var GATEWAY_TOOL = {
469
670
  name: HERDR_LINK_GATEWAY,
470
- description: '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 ({}) to activate it for this session \u2014 the host is notified via notifications/tools/list_changed and herdr_link_peers / herdr_link_send / herdr_link_close become available as regular tools. If your host did not refresh its tool list, keep dispatching through the gateway: {"action":"peers"}, {"action":"send","arguments":{"to":...,"message":...}}, or {"action":"close","arguments":{"agent":...}}.',
671
+ description: '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 ({}) to activate it for this session \u2014 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. If your host did not refresh its tool list, keep dispatching through the gateway: {"action":"start","arguments":{...}}, {"action":"peers"}, {"action":"send","arguments":{"to":...,"message":...}}, or {"action":"close","arguments":{"agent":...}}.',
471
672
  inputSchema: {
472
673
  type: "object",
473
674
  properties: {
474
675
  action: {
475
676
  type: "string",
476
- enum: ["activate", "peers", "send", "close"],
477
- description: 'Omit or use "activate" to turn the session on; other values dispatch the corresponding peers, send, or close capability.'
677
+ enum: ["activate", "start", "peers", "send", "close"],
678
+ description: 'Omit or use "activate" to turn the session on; other values dispatch the corresponding start, peers, send, or close capability.'
478
679
  },
479
680
  arguments: {
480
681
  type: "object",
@@ -547,6 +748,7 @@ function createRequestHandler(deps = {}) {
547
748
  const runPeers = deps.listPeers ?? listPeers;
548
749
  const runSend = deps.sendMessage ?? sendMessage;
549
750
  const runClose = deps.closeAgentPane ?? closeAgentPane;
751
+ const runStart2 = deps.startAgent ?? startAgent;
550
752
  const notify = deps.notify ?? stdoutNotificationSink;
551
753
  let activated = false;
552
754
  function activateSession() {
@@ -572,6 +774,8 @@ function createRequestHandler(deps = {}) {
572
774
  }
573
775
  async function executeCanonical(canonicalName, args) {
574
776
  switch (canonicalName) {
777
+ case TOOL_START:
778
+ return await runStart2(args);
575
779
  case TOOL_PEERS:
576
780
  return await runPeers();
577
781
  case TOOL_SEND: {
@@ -602,15 +806,15 @@ function createRequestHandler(deps = {}) {
602
806
  activateSession();
603
807
  return callSuccess(id, {
604
808
  status: "active",
605
- capabilities: ["peers", "send", "close"]
809
+ capabilities: ["start", "peers", "send", "close"]
606
810
  });
607
811
  }
608
- if (typeof action !== "string" || !["peers", "send", "close"].includes(action)) {
812
+ if (typeof action !== "string" || !["start", "peers", "send", "close"].includes(action)) {
609
813
  return fail(id, INVALID_PARAMS, `Unknown gateway action: ${String(action)}`);
610
814
  }
611
- const canonicalName = action === "peers" ? TOOL_PEERS : action === "send" ? TOOL_SEND : TOOL_CLOSE;
815
+ const canonicalName = action === "start" ? TOOL_START : action === "peers" ? TOOL_PEERS : action === "send" ? TOOL_SEND : TOOL_CLOSE;
612
816
  activateSession();
613
- const dispatchArgs = isRecord(args.arguments) ? args.arguments : args;
817
+ const dispatchArgs = isRecord(args.arguments) ? args.arguments : Object.fromEntries(Object.entries(args).filter(([key]) => key !== "action"));
614
818
  return await callCanonicalTool(id, canonicalName, dispatchArgs);
615
819
  }
616
820
  async function callTool(id, params) {
@@ -710,15 +914,18 @@ function contractWithAppendix(appendix) {
710
914
  ${appendix}`;
711
915
  }
712
916
  function buildMcpPrefixedCommunicationContract(namespace) {
713
- const [peers, send, close] = HERDR_LINK_TOOLS.map(
917
+ const [peers, send, close] = HERDR_LINK_COMMUNICATION_TOOLS.map(
714
918
  (name) => mcpPresentedToolName(name, namespace)
715
919
  );
920
+ const start = mcpPresentedToolName(TOOL_START, namespace);
716
921
  const gateway = mcpPresentedToolName(HERDR_LINK_GATEWAY, namespace);
717
922
  return contractWithAppendix(
718
923
  `In this runtime Herdr Link starts dormant: only the ${gateway} gateway tool is listed until it is activated.
719
924
  - Call ${gateway} once with no arguments ({}); the host then receives notifications/tools/list_changed and the cross-agent tools become available.
720
- - If the host did not refresh its tool list, keep dispatching through the gateway: {"action":"peers"}, {"action":"send","arguments":{...}}, {"action":"close","arguments":{...}}.
925
+ - 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":{...}}.
926
+ - ${START_TOOL_DESCRIPTION}
721
927
  The tools are presented under MCP-prefixed names (the canonical name is always the suffix):
928
+ - herdr_link_start -> ${start}
722
929
  - herdr_link_peers -> ${peers}
723
930
  - herdr_link_send -> ${send}
724
931
  - herdr_link_close -> ${close}`
@@ -728,13 +935,14 @@ function buildMcpWrapperCommunicationContract(wrapperName, serverName) {
728
935
  return contractWithAppendix(
729
936
  `In this runtime Herdr Link starts dormant: only the Tier 0 gateway (${HERDR_LINK_GATEWAY}) is listed until it is activated.
730
937
  - 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.
731
- - 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", ...}.
938
+ - 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", ...}.
939
+ - ${START_TOOL_DESCRIPTION}
732
940
 
733
941
  After activation, Herdr Link MCP tools are invoked through ${wrapperName}.
734
942
 
735
943
  Use:
736
944
  - ServerName: "${serverName}"
737
- - ToolName: "herdr_link_peers", "herdr_link_send", or "herdr_link_close"
945
+ - ToolName: "herdr_link_start", "herdr_link_peers", "herdr_link_send", or "herdr_link_close"
738
946
  - Arguments: the canonical input object for that Herdr Link tool`
739
947
  );
740
948
  }