herdr-link 0.4.1 → 0.5.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.
@@ -8,7 +8,7 @@ import { pathToFileURL } from "node:url";
8
8
  import { execFile } from "node:child_process";
9
9
  import { readFile } from "node:fs/promises";
10
10
  import { randomBytes } from "node:crypto";
11
- import { resolve } from "node:path";
11
+ import { isAbsolute, resolve } from "node:path";
12
12
 
13
13
  // src/protocol.ts
14
14
  var PROTOCOL_ID = "herdr-link/1";
@@ -30,7 +30,7 @@ function toAgentState(value) {
30
30
  }
31
31
  return "unknown";
32
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.";
33
+ var START_TOOL_DESCRIPTION = "Start a Herdr agent with Link-managed placement.";
34
34
  var HerdrLinkError = class extends Error {
35
35
  code;
36
36
  constructor(code, detail) {
@@ -105,17 +105,14 @@ function buildInboundWrapper(envelope) {
105
105
  );
106
106
  return lines.join("\n");
107
107
  }
108
- var COMMUNICATION_CONTRACT = `Herdr Link is the standard interoperability channel between agents running in the same Herdr workspace.
108
+ var COMMUNICATION_CONTRACT = `Herdr Link is the agent channel for the current Herdr workspace.
109
109
 
110
- 1. Use herdr_link_peers only for agent-address discovery or explicit recovery. Its activity state is advisory and must not be used to wait for or infer task completion. When further progress depends on a peer reply, end the current turn and continue when that reply arrives as a new inbound herdr-link/1 message.
111
- 2. Use herdr_link_send to send messages to another agent.
112
- 3. A message with protocol "herdr-link/1" is an inter-agent message.
113
- 4. Treat its "message" field as content sent by the agent named in "from".
114
- 5. When replying, use herdr_link_send to the agent named in "from".
115
- 6. When a received inter-agent message requests work, report the final outcome to the agent named in "from" using herdr_link_send. If specific reply content was requested, send that result; otherwise, after successful completion, send exactly "done". If the work cannot be completed, send a concise failure or blocker. If the sender explicitly requested no reply, do not send a completion message.
116
- 7. Use herdr_link_close only when you have already decided that a named agent's pane should be closed. If a final message is needed, call close in a later tool step after herdr_link_send returns "sent".
117
- 8. Never use a raw pane id, UI focus, terminal input, or the Herdr CLI as an inter-agent channel; agent names are the only addresses.
118
- 9. Agents outside your workspace are invisible: they never appear in peers and messages addressed to them fail.`;
110
+ 1. Reply path: herdr_link_send \u2192 end this turn \u2192 inbound Herdr Link message. Never wait or poll for the reply; "sent" is delivery only.
111
+ 2. Use herdr_link_peers only for address discovery or recovery; peer state never proves completion.
112
+ 3. Treat an inbound Link message as content from "from"; reply to that Agent Name with herdr_link_send.
113
+ 4. Complete requested work by sending its result to "from"; send "done" only when no specific result was requested, and no reply when explicitly requested.
114
+ 5. Use herdr_link_close only after the agent lifecycle is complete.
115
+ 6. Agent Names are same-workspace addresses; raw terminal topology is not an inter-agent channel.`;
119
116
 
120
117
  // src/herdr.ts
121
118
  function attachCliOutput(error, stdout, stderr) {
@@ -180,7 +177,7 @@ async function runFor(args, failureCode) {
180
177
  var startCursors = /* @__PURE__ */ new Map();
181
178
  var startLocks = /* @__PURE__ */ new Map();
182
179
  var START_CONFIG_PATH_PARTS = [".agents", "agent_config.json"];
183
- var START_INPUT_KEYS = /* @__PURE__ */ new Set(["name", "pane", "config_agent", "kind", "args"]);
180
+ var START_INPUT_KEYS = /* @__PURE__ */ new Set(["name", "with", "cwd", "config_agent", "kind", "args"]);
184
181
  function hasOwn(value, key) {
185
182
  return Object.prototype.hasOwnProperty.call(value, key);
186
183
  }
@@ -200,9 +197,21 @@ function validateStartInput(input) {
200
197
  if (typeof name !== "string" || !isValidAgentName(name)) {
201
198
  throw startInputError('"name" must be a valid Herdr Agent Name');
202
199
  }
203
- const pane = value.pane;
204
- if (typeof pane !== "string" || pane.trim() === "") {
205
- throw startInputError('"pane" must be a non-empty pane id');
200
+ let withName;
201
+ if (hasOwn(value, "with")) {
202
+ const withValue = value.with;
203
+ if (typeof withValue !== "string" || !isValidAgentName(withValue)) {
204
+ throw startInputError('"with" must be a valid Herdr Agent Name');
205
+ }
206
+ withName = withValue;
207
+ }
208
+ let cwd;
209
+ if (hasOwn(value, "cwd")) {
210
+ const cwdValue = value.cwd;
211
+ if (typeof cwdValue !== "string" || cwdValue.trim() === "") {
212
+ throw startInputError('"cwd" must be a non-empty string');
213
+ }
214
+ cwd = cwdValue;
206
215
  }
207
216
  const hasConfigAgent = hasOwn(value, "config_agent");
208
217
  const hasKind = hasOwn(value, "kind");
@@ -215,7 +224,7 @@ function validateStartInput(input) {
215
224
  if (typeof configAgent !== "string" || configAgent.trim() === "") {
216
225
  throw startInputError('"config_agent" must be a non-empty string');
217
226
  }
218
- return { mode: "configured", name, pane, configAgent };
227
+ return { mode: "configured", name, configAgent, ...withName !== void 0 ? { withName } : {}, ...cwd !== void 0 ? { cwd } : {} };
219
228
  }
220
229
  if (!hasKind || !hasArgs) {
221
230
  throw startInputError("explicit start requires both kind and args");
@@ -228,7 +237,10 @@ function validateStartInput(input) {
228
237
  if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) {
229
238
  throw startInputError('"args" must be an array of strings');
230
239
  }
231
- return { mode: "explicit", name, pane, variant: { kind, args: [...args] } };
240
+ if (withName !== void 0 && cwd !== void 0) {
241
+ throw startInputError('"cwd" cannot be combined with "with"');
242
+ }
243
+ return { mode: "explicit", name, variant: { kind, args: [...args] }, ...withName !== void 0 ? { withName } : {}, ...cwd !== void 0 ? { cwd } : {} };
232
244
  }
233
245
  function assertAllowedKeys(value, allowed, label) {
234
246
  const allowedSet = new Set(allowed);
@@ -239,8 +251,7 @@ function assertAllowedKeys(value, allowed, label) {
239
251
  function validateConfiguredDocument(document) {
240
252
  const root = asRecord(document);
241
253
  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");
254
+ assertAllowedKeys(root, ["agents"], "configuration root");
244
255
  const agents = asRecord(root.agents);
245
256
  if (!agents) throw startConfigError("START_CONFIG_INVALID", "agents must be an object");
246
257
  const result = /* @__PURE__ */ new Map();
@@ -250,7 +261,11 @@ function validateConfiguredDocument(document) {
250
261
  }
251
262
  const entry = asRecord(rawEntry);
252
263
  if (!entry) throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent} must be an object`);
253
- assertAllowedKeys(entry, ["strategy", "variants"], `agents.${configAgent}`);
264
+ assertAllowedKeys(entry, ["placement", "strategy", "variants"], `agents.${configAgent}`);
265
+ if (!hasOwn(entry, "placement")) {
266
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.placement is required`);
267
+ }
268
+ const placement = validatePlacement(entry.placement, configAgent);
254
269
  const rawVariants = entry.variants;
255
270
  if (!Array.isArray(rawVariants) || rawVariants.length === 0) {
256
271
  throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.variants must be non-empty`);
@@ -277,12 +292,35 @@ function validateConfiguredDocument(document) {
277
292
  return { kind, args: Array.isArray(args) ? [...args] : [] };
278
293
  });
279
294
  result.set(configAgent, {
295
+ placement,
280
296
  ...hasStrategy ? { strategy: "round-robin" } : {},
281
297
  variants
282
298
  });
283
299
  }
284
300
  return result;
285
301
  }
302
+ function validatePlacement(rawPlacement, configAgent) {
303
+ const placement = asRecord(rawPlacement);
304
+ if (!placement) {
305
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.placement must be an object`);
306
+ }
307
+ if (placement.mode === "new_tab") {
308
+ assertAllowedKeys(placement, ["mode", "label"], `agents.${configAgent}.placement`);
309
+ let label;
310
+ if (hasOwn(placement, "label")) {
311
+ if (typeof placement.label !== "string" || placement.label.trim() === "") {
312
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.placement.label must be a non-empty string`);
313
+ }
314
+ label = placement.label;
315
+ }
316
+ return { mode: "new_tab", ...label !== void 0 ? { label } : {} };
317
+ }
318
+ if (placement.mode === "with") {
319
+ assertAllowedKeys(placement, ["mode"], `agents.${configAgent}.placement`);
320
+ return { mode: "with" };
321
+ }
322
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.placement.mode is unsupported`);
323
+ }
286
324
  async function loadConfiguredStartAgents(configPath) {
287
325
  let text;
288
326
  try {
@@ -325,15 +363,124 @@ async function runStart(name, pane, variant) {
325
363
  throw operationError(error, "START_FAILED");
326
364
  }
327
365
  }
366
+ function startFailure(detail) {
367
+ return new HerdrLinkError("START_FAILED", detail);
368
+ }
369
+ function resolvePlacement(validated, configPlacement) {
370
+ if (validated.mode === "configured") {
371
+ if (configPlacement?.mode === "new_tab") {
372
+ if (validated.withName !== void 0) {
373
+ throw startInputError('"with" is not allowed for a new_tab configured placement');
374
+ }
375
+ return { kind: "new-tab", label: configPlacement.label };
376
+ }
377
+ if (validated.withName === void 0) {
378
+ throw startInputError('configured "with" placement requires "with"');
379
+ }
380
+ if (validated.cwd !== void 0) {
381
+ throw startInputError('"cwd" is not allowed for a "with" placement');
382
+ }
383
+ return { kind: "with", withName: validated.withName };
384
+ }
385
+ return validated.withName !== void 0 ? { kind: "with", withName: validated.withName } : { kind: "new-tab" };
386
+ }
387
+ function resolveLaunchCwd(contextDirectory, inputCwd) {
388
+ if (inputCwd === void 0) return contextDirectory;
389
+ return isAbsolute(inputCwd) ? inputCwd : resolve(contextDirectory, inputCwd);
390
+ }
391
+ async function bestEffortCloseTab(tabId) {
392
+ try {
393
+ await runFor(["tab", "close", tabId], "START_FAILED");
394
+ } catch {
395
+ }
396
+ }
397
+ async function bestEffortClosePane(paneId) {
398
+ try {
399
+ await runFor(["pane", "close", paneId], "START_FAILED");
400
+ } catch {
401
+ }
402
+ }
403
+ async function allocateNewTab(contextDirectory, inputCwd, label) {
404
+ const self = await getSelfContext();
405
+ const launchCwd = resolveLaunchCwd(contextDirectory, inputCwd);
406
+ const created = await runFor(
407
+ [
408
+ "tab",
409
+ "create",
410
+ "--workspace",
411
+ self.workspace_id,
412
+ "--cwd",
413
+ launchCwd,
414
+ ...label !== void 0 ? ["--label", label] : [],
415
+ "--no-focus"
416
+ ],
417
+ "START_FAILED"
418
+ );
419
+ const createdTabId = parseTabCreateResult(created);
420
+ if (createdTabId === void 0) throw startFailure("created tab reported no tab id");
421
+ try {
422
+ const panes = await runFor(["pane", "list", "--workspace", self.workspace_id], "START_FAILED");
423
+ const rootPanes = filterPanesByTab(panes, createdTabId);
424
+ if (rootPanes.length !== 1 || rootPanes[0] === void 0) {
425
+ throw startFailure("created tab must contain exactly one root pane");
426
+ }
427
+ return {
428
+ paneId: rootPanes[0],
429
+ rollback: () => bestEffortCloseTab(createdTabId)
430
+ };
431
+ } catch (error) {
432
+ await bestEffortCloseTab(createdTabId);
433
+ throw error;
434
+ }
435
+ }
436
+ async function allocateWith(withName) {
437
+ const self = await getSelfContext();
438
+ const anchor = await getAgentContext(withName);
439
+ assertSameWorkspace(self, anchor);
440
+ const anchorPane = await getPaneCwd(anchor.pane_id);
441
+ if (anchorPane.workspace_id === "" || anchorPane.workspace_id !== self.workspace_id) {
442
+ throw new HerdrLinkError("PEER_NOT_FOUND", AGENT_ERROR_DETAILS.PEER_NOT_FOUND);
443
+ }
444
+ if (anchorPane.cwd === void 0 || anchorPane.cwd === "") {
445
+ throw startFailure(`anchor pane ${anchor.pane_id} has no cwd; cannot inherit worktree binding`);
446
+ }
447
+ const split = await runFor(
448
+ [
449
+ "pane",
450
+ "split",
451
+ anchor.pane_id,
452
+ "--direction",
453
+ "right",
454
+ "--cwd",
455
+ anchorPane.cwd,
456
+ "--no-focus"
457
+ ],
458
+ "START_FAILED"
459
+ );
460
+ const newPaneId = parsePaneSplitResult(split);
461
+ if (newPaneId === void 0) throw startFailure("pane split returned no created pane id");
462
+ return {
463
+ paneId: newPaneId,
464
+ rollback: () => bestEffortClosePane(newPaneId)
465
+ };
466
+ }
328
467
  async function startAgent(input, options = {}) {
329
468
  assertHerdrEnvironment();
330
469
  const validated = validateStartInput(input);
470
+ const contextDirectory = typeof options.contextDirectory === "string" && options.contextDirectory.trim() !== "" ? options.contextDirectory : process.cwd();
331
471
  if (validated.mode === "explicit") {
332
- await runStart(validated.name, validated.pane, validated.variant);
472
+ const placement = resolvePlacement(validated, void 0);
473
+ const allocation = placement.kind === "new-tab" ? await allocateNewTab(contextDirectory, validated.cwd, placement.label) : await allocateWith(placement.withName);
474
+ try {
475
+ await runStart(validated.name, allocation.paneId, validated.variant);
476
+ } catch (error) {
477
+ await allocation.rollback().catch(() => {
478
+ });
479
+ throw error;
480
+ }
333
481
  return { status: "started", agent: validated.name, kind: validated.variant.kind };
334
482
  }
335
- const projectRoot = typeof options.cwd === "string" && options.cwd.trim() !== "" ? options.cwd : process.cwd();
336
- const configPath = resolve(projectRoot, ...START_CONFIG_PATH_PARTS);
483
+ const configPath = resolve(contextDirectory, ...START_CONFIG_PATH_PARTS);
337
484
  const cursorKey = `${configPath}\0${validated.configAgent}`;
338
485
  return withStartCursorLock(cursorKey, async () => {
339
486
  const configuredAgents = await loadConfiguredStartAgents(configPath);
@@ -341,14 +488,22 @@ async function startAgent(input, options = {}) {
341
488
  if (!configured) {
342
489
  throw startConfigError("START_AGENT_NOT_FOUND", `configured Agent "${validated.configAgent}" was not found`);
343
490
  }
491
+ const placement = resolvePlacement(validated, configured.placement);
344
492
  const current = startCursors.get(cursorKey) ?? 0;
345
493
  const variantIndex = current % configured.variants.length;
346
494
  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);
495
+ const allocation = placement.kind === "new-tab" ? await allocateNewTab(contextDirectory, validated.cwd, placement.label) : await allocateWith(placement.withName);
496
+ try {
497
+ await runStart(validated.name, allocation.paneId, variant);
498
+ if (configured.variants.length > 1) {
499
+ startCursors.set(cursorKey, (variantIndex + 1) % configured.variants.length);
500
+ }
501
+ return { status: "started", agent: validated.name, kind: variant.kind };
502
+ } catch (error) {
503
+ await allocation.rollback().catch(() => {
504
+ });
505
+ throw error;
350
506
  }
351
- return { status: "started", agent: validated.name, kind: variant.kind };
352
507
  });
353
508
  }
354
509
  var CLI_ERROR_CODE_MAP = {
@@ -413,6 +568,47 @@ function agentList(value) {
413
568
  const agents = result?.agents ?? root?.agents;
414
569
  return Array.isArray(agents) ? agents : [];
415
570
  }
571
+ function parseTabCreateResult(value) {
572
+ const root = asRecord(value);
573
+ const result = asRecord(root?.result);
574
+ const tab = asRecord(result?.tab) ?? asRecord(root?.tab) ?? asRecord(result);
575
+ return nonEmptyString(tab?.tab_id);
576
+ }
577
+ function filterPanesByTab(value, tabId) {
578
+ const root = asRecord(value);
579
+ const result = asRecord(root?.result);
580
+ const rawPanes = result?.panes;
581
+ if (!Array.isArray(rawPanes)) return [];
582
+ const paneIds = [];
583
+ for (const raw of rawPanes) {
584
+ const pane = asRecord(raw);
585
+ if (pane && pane.tab_id === tabId) {
586
+ const paneId = nonEmptyString(pane.pane_id);
587
+ if (paneId !== void 0) paneIds.push(paneId);
588
+ }
589
+ }
590
+ return paneIds;
591
+ }
592
+ function parsePaneInfoResult(value) {
593
+ const root = asRecord(value);
594
+ const result = asRecord(root?.result);
595
+ const pane = asRecord(result?.pane) ?? asRecord(root);
596
+ return {
597
+ workspace_id: nonEmptyString(pane?.workspace_id),
598
+ cwd: nonEmptyString(pane?.cwd),
599
+ tab_id: nonEmptyString(pane?.tab_id)
600
+ };
601
+ }
602
+ async function getPaneCwd(paneId) {
603
+ const response = await runFor(["pane", "get", paneId], "START_FAILED");
604
+ return parsePaneInfoResult(response);
605
+ }
606
+ function parsePaneSplitResult(value) {
607
+ const root = asRecord(value);
608
+ const result = asRecord(root?.result);
609
+ const pane = asRecord(result?.pane) ?? asRecord(result);
610
+ return nonEmptyString(pane?.pane_id);
611
+ }
416
612
  function nonEmptyString(value) {
417
613
  return typeof value === "string" && value.length > 0 ? value : void 0;
418
614
  }
@@ -613,7 +809,7 @@ async function closeAgentPane(agentName) {
613
809
 
614
810
  // src/mcp.ts
615
811
  var MCP_SERVER_NAME = "herdr-link";
616
- var MCP_SERVER_VERSION = "0.4.1";
812
+ var MCP_SERVER_VERSION = "0.5.0";
617
813
  var MCP_PROTOCOL_VERSION = "2025-06-18";
618
814
  var TOOLS_LIST_CHANGED = "notifications/tools/list_changed";
619
815
  var PARSE_ERROR = -32700;
@@ -623,21 +819,22 @@ var INVALID_PARAMS = -32602;
623
819
  var NORMAL_MESSAGING_RULE = "Use Herdr Link, not raw Herdr CLI, pane ids, or terminal input, for normal inter-agent messaging.";
624
820
  var TOOL_DESCRIPTIONS = {
625
821
  [TOOL_START]: `${START_TOOL_DESCRIPTION} ${NORMAL_MESSAGING_RULE}`,
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}`,
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}`,
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}`
822
+ [TOOL_PEERS]: `List live same-workspace agent names. ${NORMAL_MESSAGING_RULE}`,
823
+ [TOOL_SEND]: `Send a Link message; "sent" is delivery only. ${NORMAL_MESSAGING_RULE}`,
824
+ [TOOL_CLOSE]: `Close a named agent's pane. If a final message is needed, send first and close in a later tool step. ${NORMAL_MESSAGING_RULE}`
629
825
  };
630
826
  var TOOL_INPUT_SCHEMAS = {
631
827
  [TOOL_START]: {
632
828
  type: "object",
633
829
  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" }
830
+ name: { type: "string", description: "New agent name." },
831
+ with: { type: "string", description: "Co-locate with this agent." },
832
+ cwd: { type: "string", description: "New-tab working directory." },
833
+ config_agent: { type: "string", description: "Config key." },
834
+ kind: { type: "string", description: "Agent kind." },
835
+ args: { type: "array", items: { type: "string" }, description: "Agent arguments." }
639
836
  },
640
- required: ["name", "pane"],
837
+ required: ["name"],
641
838
  oneOf: [
642
839
  { required: ["config_agent"], not: { anyOf: [{ required: ["kind"] }, { required: ["args"] }] } },
643
840
  { required: ["kind", "args"], not: { required: ["config_agent"] } }