herdr-link 0.4.0 → 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.
package/src/herdr.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { randomBytes } from "node:crypto";
4
- import { resolve } from "node:path";
4
+ import { isAbsolute, resolve } from "node:path";
5
5
  import {
6
6
  AGENT_ERROR_DETAILS,
7
7
  buildEnvelope,
@@ -108,7 +108,13 @@ interface ValidatedStartVariant {
108
108
  args: string[];
109
109
  }
110
110
 
111
+ /** Placing a configured Agent: a fresh tab, or co-located with a live anchor Agent. */
112
+ type ValidatedPlacement =
113
+ | { mode: "new_tab"; label?: string }
114
+ | { mode: "with" };
115
+
111
116
  interface ConfiguredStartAgent {
117
+ placement: ValidatedPlacement;
112
118
  strategy?: "round-robin";
113
119
  variants: ValidatedStartVariant[];
114
120
  }
@@ -116,7 +122,7 @@ interface ConfiguredStartAgent {
116
122
  const startCursors = new Map<string, number>();
117
123
  const startLocks = new Map<string, Promise<void>>();
118
124
  const START_CONFIG_PATH_PARTS = [".agents", "agent_config.json"] as const;
119
- const START_INPUT_KEYS = new Set(["name", "pane", "config_agent", "kind", "args"]);
125
+ const START_INPUT_KEYS = new Set(["name", "with", "cwd", "config_agent", "kind", "args"]);
120
126
 
121
127
  /** @internal Test seam only: clears process-local configured-start state. */
122
128
  export function resetStartStateForTests(): void {
@@ -140,8 +146,8 @@ function startConfigError(
140
146
  }
141
147
 
142
148
  function validateStartInput(input: unknown):
143
- | { mode: "configured"; name: string; pane: string; configAgent: string }
144
- | { mode: "explicit"; name: string; pane: string; variant: ValidatedStartVariant } {
149
+ | { mode: "configured"; name: string; configAgent: string; withName?: string; cwd?: string }
150
+ | { mode: "explicit"; name: string; variant: ValidatedStartVariant; withName?: string; cwd?: string } {
145
151
  const value = asRecord(input);
146
152
  if (!value) throw startInputError("start input must be an object");
147
153
  for (const key of Object.keys(value)) {
@@ -150,11 +156,25 @@ function validateStartInput(input: unknown):
150
156
 
151
157
  const name = value.name;
152
158
  if (typeof name !== "string" || !isValidAgentName(name)) {
153
- throw startInputError("\"name\" must be a valid Herdr Agent Name");
159
+ throw startInputError('"name" must be a valid Herdr Agent Name');
154
160
  }
155
- const pane = value.pane;
156
- if (typeof pane !== "string" || pane.trim() === "") {
157
- throw startInputError("\"pane\" must be a non-empty pane id");
161
+
162
+ let withName: string | undefined;
163
+ if (hasOwn(value, "with")) {
164
+ const withValue = value.with;
165
+ if (typeof withValue !== "string" || !isValidAgentName(withValue)) {
166
+ throw startInputError('"with" must be a valid Herdr Agent Name');
167
+ }
168
+ withName = withValue;
169
+ }
170
+
171
+ let cwd: string | undefined;
172
+ if (hasOwn(value, "cwd")) {
173
+ const cwdValue = value.cwd;
174
+ if (typeof cwdValue !== "string" || cwdValue.trim() === "") {
175
+ throw startInputError('"cwd" must be a non-empty string');
176
+ }
177
+ cwd = cwdValue;
158
178
  }
159
179
 
160
180
  const hasConfigAgent = hasOwn(value, "config_agent");
@@ -166,9 +186,9 @@ function validateStartInput(input: unknown):
166
186
  if (hasConfigAgent) {
167
187
  const configAgent = value.config_agent;
168
188
  if (typeof configAgent !== "string" || configAgent.trim() === "") {
169
- throw startInputError("\"config_agent\" must be a non-empty string");
189
+ throw startInputError('"config_agent" must be a non-empty string');
170
190
  }
171
- return { mode: "configured", name, pane, configAgent };
191
+ return { mode: "configured", name, configAgent, ...(withName !== undefined ? { withName } : {}), ...(cwd !== undefined ? { cwd } : {}) };
172
192
  }
173
193
 
174
194
  if (!hasKind || !hasArgs) {
@@ -176,13 +196,17 @@ function validateStartInput(input: unknown):
176
196
  }
177
197
  const kind = value.kind;
178
198
  if (typeof kind !== "string" || kind.trim() === "") {
179
- throw startInputError("\"kind\" must be a non-empty string");
199
+ throw startInputError('"kind" must be a non-empty string');
180
200
  }
181
201
  const args = value.args;
182
202
  if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) {
183
- throw startInputError("\"args\" must be an array of strings");
203
+ throw startInputError('"args" must be an array of strings');
204
+ }
205
+ // Explicit `with` (same-tab placement) never takes a launch `cwd`.
206
+ if (withName !== undefined && cwd !== undefined) {
207
+ throw startInputError('"cwd" cannot be combined with "with"');
184
208
  }
185
- return { mode: "explicit", name, pane, variant: { kind, args: [...args] } };
209
+ return { mode: "explicit", name, variant: { kind, args: [...args] }, ...(withName !== undefined ? { withName } : {}), ...(cwd !== undefined ? { cwd } : {}) };
186
210
  }
187
211
 
188
212
  function assertAllowedKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
@@ -195,8 +219,7 @@ function assertAllowedKeys(value: Record<string, unknown>, allowed: readonly str
195
219
  function validateConfiguredDocument(document: unknown): Map<string, ConfiguredStartAgent> {
196
220
  const root = asRecord(document);
197
221
  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");
222
+ assertAllowedKeys(root, ["agents"], "configuration root");
200
223
 
201
224
  const agents = asRecord(root.agents);
202
225
  if (!agents) throw startConfigError("START_CONFIG_INVALID", "agents must be an object");
@@ -208,7 +231,11 @@ function validateConfiguredDocument(document: unknown): Map<string, ConfiguredSt
208
231
  }
209
232
  const entry = asRecord(rawEntry);
210
233
  if (!entry) throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent} must be an object`);
211
- assertAllowedKeys(entry, ["strategy", "variants"], `agents.${configAgent}`);
234
+ assertAllowedKeys(entry, ["placement", "strategy", "variants"], `agents.${configAgent}`);
235
+ if (!hasOwn(entry, "placement")) {
236
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.placement is required`);
237
+ }
238
+ const placement = validatePlacement(entry.placement, configAgent);
212
239
 
213
240
  const rawVariants = entry.variants;
214
241
  if (!Array.isArray(rawVariants) || rawVariants.length === 0) {
@@ -238,6 +265,7 @@ function validateConfiguredDocument(document: unknown): Map<string, ConfiguredSt
238
265
  });
239
266
 
240
267
  result.set(configAgent, {
268
+ placement,
241
269
  ...(hasStrategy ? { strategy: "round-robin" as const } : {}),
242
270
  variants,
243
271
  });
@@ -245,6 +273,30 @@ function validateConfiguredDocument(document: unknown): Map<string, ConfiguredSt
245
273
  return result;
246
274
  }
247
275
 
276
+ /** Strict placement validation: `new_tab` with optional presentation-only label, or `with` without a label. */
277
+ function validatePlacement(rawPlacement: unknown, configAgent: string): ValidatedPlacement {
278
+ const placement = asRecord(rawPlacement);
279
+ if (!placement) {
280
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.placement must be an object`);
281
+ }
282
+ if (placement.mode === "new_tab") {
283
+ assertAllowedKeys(placement, ["mode", "label"], `agents.${configAgent}.placement`);
284
+ let label: string | undefined;
285
+ if (hasOwn(placement, "label")) {
286
+ if (typeof placement.label !== "string" || placement.label.trim() === "") {
287
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.placement.label must be a non-empty string`);
288
+ }
289
+ label = placement.label;
290
+ }
291
+ return { mode: "new_tab", ...(label !== undefined ? { label } : {}) };
292
+ }
293
+ if (placement.mode === "with") {
294
+ assertAllowedKeys(placement, ["mode"], `agents.${configAgent}.placement`);
295
+ return { mode: "with" };
296
+ }
297
+ throw startConfigError("START_CONFIG_INVALID", `agents.${configAgent}.placement.mode is unsupported`);
298
+ }
299
+
248
300
  async function loadConfiguredStartAgents(configPath: string): Promise<Map<string, ConfiguredStartAgent>> {
249
301
  let text: string;
250
302
  try {
@@ -295,22 +347,185 @@ async function runStart(name: string, pane: string, variant: ValidatedStartVaria
295
347
  }
296
348
 
297
349
  export interface StartAgentOptions {
298
- /** Runtime context directory used to locate the optional project config. */
299
- cwd?: string;
350
+ /**
351
+ * Adapter context directory: locates `.agents/agent_config.json`, is the
352
+ * default launch cwd for a new tab, and is the resolution base for a
353
+ * relative model `cwd`. Never used as the launch cwd of a `with` placement.
354
+ */
355
+ contextDirectory?: string;
356
+ }
357
+
358
+ type ResolvedPlacement =
359
+ | { kind: "new-tab"; label?: string }
360
+ | { kind: "with"; withName: string };
361
+
362
+ type PlacementAllocation = {
363
+ paneId: string;
364
+ rollback(): Promise<void>;
365
+ };
366
+
367
+ function startFailure(detail: string): HerdrLinkError {
368
+ return new HerdrLinkError("START_FAILED", detail);
369
+ }
370
+
371
+ /**
372
+ * Resolves the effective placement for a validated start. Configured mode
373
+ * derives it from the declared placement; explicit mode derives it from the
374
+ * presence of `with` (present → same-tab, absent → new-tab).
375
+ */
376
+ function resolvePlacement(
377
+ validated: ReturnType<typeof validateStartInput>,
378
+ configPlacement: ValidatedPlacement | undefined,
379
+ ): ResolvedPlacement {
380
+ if (validated.mode === "configured") {
381
+ if (configPlacement?.mode === "new_tab") {
382
+ if (validated.withName !== undefined) {
383
+ throw startInputError('"with" is not allowed for a new_tab configured placement');
384
+ }
385
+ return { kind: "new-tab", label: configPlacement.label };
386
+ }
387
+ // configured `with`:
388
+ if (validated.withName === undefined) {
389
+ throw startInputError('configured "with" placement requires "with"');
390
+ }
391
+ if (validated.cwd !== undefined) {
392
+ throw startInputError('"cwd" is not allowed for a "with" placement');
393
+ }
394
+ return { kind: "with", withName: validated.withName };
395
+ }
396
+ return validated.withName !== undefined
397
+ ? { kind: "with", withName: validated.withName }
398
+ : { kind: "new-tab" };
399
+ }
400
+
401
+ /**
402
+ * New-tab launch cwd: `input.cwd` resolved against the context directory
403
+ * when relative, used as-is when absolute; otherwise the context directory
404
+ * itself. NEVER affects where `.agents/agent_config.json` is looked up.
405
+ */
406
+ function resolveLaunchCwd(contextDirectory: string, inputCwd: string | undefined): string {
407
+ if (inputCwd === undefined) return contextDirectory;
408
+ return isAbsolute(inputCwd) ? inputCwd : resolve(contextDirectory, inputCwd);
409
+ }
410
+
411
+ /** Best-effort tab close; rollback failures never replace the primary error. */
412
+ async function bestEffortCloseTab(tabId: string): Promise<void> {
413
+ try {
414
+ await runFor(["tab", "close", tabId], "START_FAILED");
415
+ } catch {
416
+ // Preserve the primary start failure classification.
417
+ }
418
+ }
419
+
420
+ /** Best-effort pane close; rollback failures never replace the primary error. */
421
+ async function bestEffortClosePane(paneId: string): Promise<void> {
422
+ try {
423
+ await runFor(["pane", "close", paneId], "START_FAILED");
424
+ } catch {
425
+ // Preserve the primary start failure classification.
426
+ }
427
+ }
428
+
429
+
430
+ /**
431
+ * New-tab allocation: create a focus-free tab, then resolve its exactly-one
432
+ * root pane through a fresh `pane list` filtered by the created tab id.
433
+ * Raw topology ids stay inside this function and the returned allocation.
434
+ */
435
+ async function allocateNewTab(
436
+ contextDirectory: string,
437
+ inputCwd: string | undefined,
438
+ label: string | undefined,
439
+ ): Promise<PlacementAllocation> {
440
+ const self = await getSelfContext();
441
+ const launchCwd = resolveLaunchCwd(contextDirectory, inputCwd);
442
+ const created = await runFor(
443
+ [
444
+ "tab", "create",
445
+ "--workspace", self.workspace_id,
446
+ "--cwd", launchCwd,
447
+ ...(label !== undefined ? ["--label", label] : []),
448
+ "--no-focus",
449
+ ],
450
+ "START_FAILED",
451
+ );
452
+ const createdTabId = parseTabCreateResult(created);
453
+ if (createdTabId === undefined) throw startFailure("created tab reported no tab id");
454
+ try {
455
+ const panes = await runFor(["pane", "list", "--workspace", self.workspace_id], "START_FAILED");
456
+ const rootPanes = filterPanesByTab(panes, createdTabId);
457
+ if (rootPanes.length !== 1 || rootPanes[0] === undefined) {
458
+ throw startFailure("created tab must contain exactly one root pane");
459
+ }
460
+ return {
461
+ paneId: rootPanes[0],
462
+ rollback: () => bestEffortCloseTab(createdTabId),
463
+ };
464
+ } catch (error) {
465
+ // The tab was allocated but not yet exposed: close the exact created tab.
466
+ await bestEffortCloseTab(createdTabId);
467
+ throw error;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * `with` allocation: resolve the live anchor, enforce same-workspace, read
473
+ * the anchor pane's cwd, and split that pane so the new Agent inherits the
474
+ * anchor's live cwd. Raw pane ids stay inside this function.
475
+ */
476
+ async function allocateWith(withName: string): Promise<PlacementAllocation> {
477
+ const self = await getSelfContext();
478
+ const anchor = await getAgentContext(withName);
479
+ assertSameWorkspace(self, anchor);
480
+ const anchorPane = await getPaneCwd(anchor.pane_id);
481
+ if (anchorPane.workspace_id === "" || anchorPane.workspace_id !== self.workspace_id) {
482
+ throw new HerdrLinkError("PEER_NOT_FOUND", AGENT_ERROR_DETAILS.PEER_NOT_FOUND);
483
+ }
484
+ if (anchorPane.cwd === undefined || anchorPane.cwd === "") {
485
+ throw startFailure(`anchor pane ${anchor.pane_id} has no cwd; cannot inherit worktree binding`);
486
+ }
487
+ const split = await runFor(
488
+ [
489
+ "pane", "split", anchor.pane_id,
490
+ "--direction", "right",
491
+ "--cwd", anchorPane.cwd,
492
+ "--no-focus",
493
+ ],
494
+ "START_FAILED",
495
+ );
496
+ const newPaneId = parsePaneSplitResult(split);
497
+ if (newPaneId === undefined) throw startFailure("pane split returned no created pane id");
498
+ return {
499
+ paneId: newPaneId,
500
+ rollback: () => bestEffortClosePane(newPaneId),
501
+ };
300
502
  }
301
503
 
302
- /** Starts an Agent from a complete configured entry or a complete explicit launch specification. */
504
+ /** Starts an Agent with Link-managed placement: allocation, mech start, failed-start rollback. */
303
505
  export async function startAgent(input: StartAgentInput, options: StartAgentOptions = {}): Promise<StartAgentReceipt> {
304
506
  assertHerdrEnvironment();
305
507
  const validated = validateStartInput(input);
508
+ const contextDirectory =
509
+ typeof options.contextDirectory === "string" && options.contextDirectory.trim() !== ""
510
+ ? options.contextDirectory
511
+ : process.cwd();
306
512
 
307
513
  if (validated.mode === "explicit") {
308
- await runStart(validated.name, validated.pane, validated.variant);
514
+ const placement = resolvePlacement(validated, undefined);
515
+ const allocation =
516
+ placement.kind === "new-tab"
517
+ ? await allocateNewTab(contextDirectory, validated.cwd, placement.label)
518
+ : await allocateWith(placement.withName);
519
+ try {
520
+ await runStart(validated.name, allocation.paneId, validated.variant);
521
+ } catch (error) {
522
+ await allocation.rollback().catch(() => {});
523
+ throw error;
524
+ }
309
525
  return { status: "started", agent: validated.name, kind: validated.variant.kind };
310
526
  }
311
527
 
312
- const projectRoot = typeof options.cwd === "string" && options.cwd.trim() !== "" ? options.cwd : process.cwd();
313
- const configPath = resolve(projectRoot, ...START_CONFIG_PATH_PARTS);
528
+ const configPath = resolve(contextDirectory, ...START_CONFIG_PATH_PARTS);
314
529
  const cursorKey = `${configPath}\u0000${validated.configAgent}`;
315
530
  return withStartCursorLock(cursorKey, async () => {
316
531
  const configuredAgents = await loadConfiguredStartAgents(configPath);
@@ -319,14 +534,24 @@ export async function startAgent(input: StartAgentInput, options: StartAgentOpti
319
534
  throw startConfigError("START_AGENT_NOT_FOUND", `configured Agent "${validated.configAgent}" was not found`);
320
535
  }
321
536
 
537
+ const placement = resolvePlacement(validated, configured.placement);
322
538
  const current = startCursors.get(cursorKey) ?? 0;
323
539
  const variantIndex = current % configured.variants.length;
324
540
  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);
541
+ const allocation =
542
+ placement.kind === "new-tab"
543
+ ? await allocateNewTab(contextDirectory, validated.cwd, placement.label)
544
+ : await allocateWith(placement.withName);
545
+ try {
546
+ await runStart(validated.name, allocation.paneId, variant);
547
+ if (configured.variants.length > 1) {
548
+ startCursors.set(cursorKey, (variantIndex + 1) % configured.variants.length);
549
+ }
550
+ return { status: "started", agent: validated.name, kind: variant.kind };
551
+ } catch (error) {
552
+ await allocation.rollback().catch(() => {});
553
+ throw error;
328
554
  }
329
- return { status: "started", agent: validated.name, kind: variant.kind };
330
555
  });
331
556
  }
332
557
 
@@ -410,7 +635,75 @@ function agentList(value: unknown): unknown[] {
410
635
  }
411
636
 
412
637
  /* ------------------------------------------------------------------ *
413
- * Live record readers (blueprint v2)
638
+ * Topology parsing helpers (pure, unit-testable)
639
+ *
640
+ * Hersdr topology responses are parsed here, never guessed inline in the
641
+ * allocation flow. Shapes follow the measured Herdr CLI responses:
642
+ * - tab create -> { result: { tab: { tab_id }, root_pane: { ... } } }
643
+ * - pane list -> { result: { panes: [{ tab_id, pane_id, ... }] } }
644
+ * - pane get -> { result: { pane: { workspace_id, cwd, ... } } }
645
+ * - pane split -> { result: { pane: { pane_id, ... } } }
646
+ * ------------------------------------------------------------------ */
647
+
648
+ interface PaneInfoRecord {
649
+ workspace_id?: string;
650
+ cwd?: string;
651
+ tab_id?: string;
652
+ }
653
+
654
+ /** Parses the created tab id from `tab create` output; undefined when absent. */
655
+ function parseTabCreateResult(value: unknown): string | undefined {
656
+ const root = asRecord(value);
657
+ const result = asRecord(root?.result);
658
+ const tab = asRecord(result?.tab) ?? asRecord(root?.tab) ?? asRecord(result);
659
+ return nonEmptyString(tab?.tab_id);
660
+ }
661
+
662
+ /** Returns pane ids of the created tab from a fresh `pane list` response. */
663
+ function filterPanesByTab(value: unknown, tabId: string): string[] {
664
+ const root = asRecord(value);
665
+ const result = asRecord(root?.result);
666
+ const rawPanes = result?.panes;
667
+ if (!Array.isArray(rawPanes)) return [];
668
+ const paneIds: string[] = [];
669
+ for (const raw of rawPanes) {
670
+ const pane = asRecord(raw);
671
+ if (pane && pane.tab_id === tabId) {
672
+ const paneId = nonEmptyString(pane.pane_id);
673
+ if (paneId !== undefined) paneIds.push(paneId);
674
+ }
675
+ }
676
+ return paneIds;
677
+ }
678
+
679
+ /** Parses a pane record from `pane get` output. */
680
+ function parsePaneInfoResult(value: unknown): PaneInfoRecord {
681
+ const root = asRecord(value);
682
+ const result = asRecord(root?.result);
683
+ const pane = asRecord(result?.pane) ?? asRecord(root);
684
+ return {
685
+ workspace_id: nonEmptyString(pane?.workspace_id),
686
+ cwd: nonEmptyString(pane?.cwd),
687
+ tab_id: nonEmptyString(pane?.tab_id),
688
+ };
689
+ }
690
+
691
+ /** Fresh `pane get` for an anchor pane: authoritative cwd/workspace facts. */
692
+ async function getPaneCwd(paneId: string): Promise<PaneInfoRecord> {
693
+ const response = await runFor(["pane", "get", paneId], "START_FAILED");
694
+ return parsePaneInfoResult(response);
695
+ }
696
+
697
+ /** Parses the created sibling pane id from `pane split` output; undefined when absent. */
698
+ function parsePaneSplitResult(value: unknown): string | undefined {
699
+ const root = asRecord(value);
700
+ const result = asRecord(root?.result);
701
+ const pane = asRecord(result?.pane) ?? asRecord(result);
702
+ return nonEmptyString(pane?.pane_id);
703
+ }
704
+
705
+ /* ------------------------------------------------------------------ *
706
+ * Live record readers
414
707
  *
415
708
  * Every communication call resolves fresh records from Herdr. Ambient
416
709
  * environment values such as HERDR_WORKSPACE_ID are never consulted:
package/src/mcp.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * Tool execution reuses the herdr.ts control layer. Tool gating and error semantics follow
8
8
  * PROTOCOL.md §7; tool-name presentation follows PROTOCOL.md §4.6.
9
9
  *
10
- * Lazy presentation (blueprint v2): the tool surface is session-local and
10
+ * Lazy presentation: the tool surface is session-local and
11
11
  * dormant until activated. Outside Herdr, `tools/list` is empty. Inside
12
12
  * Herdr, a dormant session lists only the Tier 0 `herdr_link` gateway;
13
13
  * calling the gateway with `{}` activates THIS server session (per stdio
@@ -39,7 +39,7 @@ import {
39
39
 
40
40
  export const MCP_SERVER_NAME = "herdr-link";
41
41
  /** Keep in sync with package.json "version" (serverInfo is informational). */
42
- export const MCP_SERVER_VERSION = "0.2.1";
42
+ export const MCP_SERVER_VERSION = "0.5.0";
43
43
  /** Fallback protocol version advertised when the client sends none. */
44
44
  export const MCP_PROTOCOL_VERSION = "2025-06-18";
45
45
 
@@ -79,24 +79,23 @@ type CanonicalToolName = (typeof HERDR_LINK_TOOLS)[number];
79
79
  const NORMAL_MESSAGING_RULE = "Use Herdr Link, not raw Herdr CLI, pane ids, or terminal input, for normal inter-agent messaging.";
80
80
  const TOOL_DESCRIPTIONS: Record<CanonicalToolName, string> = {
81
81
  [TOOL_START]: `${START_TOOL_DESCRIPTION} ${NORMAL_MESSAGING_RULE}`,
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}`,
83
- [TOOL_SEND]:
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}`,
85
- [TOOL_CLOSE]:
86
- `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}`,
82
+ [TOOL_PEERS]: `List live same-workspace agent names. ${NORMAL_MESSAGING_RULE}`,
83
+ [TOOL_SEND]: `Send a Link message; "sent" is delivery only. ${NORMAL_MESSAGING_RULE}`,
84
+ [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}`,
87
85
  };
88
86
 
89
87
  const TOOL_INPUT_SCHEMAS: Record<CanonicalToolName, Record<string, unknown>> = {
90
88
  [TOOL_START]: {
91
89
  type: "object",
92
90
  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" },
91
+ name: { type: "string", description: "New agent name." },
92
+ with: { type: "string", description: "Co-locate with this agent." },
93
+ cwd: { type: "string", description: "New-tab working directory." },
94
+ config_agent: { type: "string", description: "Config key." },
95
+ kind: { type: "string", description: "Agent kind." },
96
+ args: { type: "array", items: { type: "string" }, description: "Agent arguments." },
98
97
  },
99
- required: ["name", "pane"],
98
+ required: ["name"],
100
99
  oneOf: [
101
100
  { required: ["config_agent"], not: { anyOf: [{ required: ["kind"] }, { required: ["args"] }] } },
102
101
  { required: ["kind", "args"], not: { required: ["config_agent"] } },
@@ -129,7 +128,7 @@ const FALLBACK_ERROR_CODE: Record<CanonicalToolName, LinkErrorCode> = {
129
128
  };
130
129
 
131
130
  /**
132
- * Tier 0 gateway tool (blueprint v2): the single always-present registration
131
+ * Tier 0 gateway tool: the single always-present registration
133
132
  * surface while dormant, and the explicit action-dispatch fallback for hosts
134
133
  * that do not react to `notifications/tools/list_changed`.
135
134
  */
@@ -274,7 +273,7 @@ export function createRequestHandler(
274
273
  const runStart = deps.startAgent ?? startAgent;
275
274
  const notify = deps.notify ?? stdoutNotificationSink;
276
275
 
277
- /** Session-local lazy activation (blueprint v2). True ⇒ Tier 1 tools are listed. */
276
+ /** Session-local lazy activation. True ⇒ Tier 1 tools are listed. */
278
277
  let activated = false;
279
278
 
280
279
  /**
@@ -443,7 +442,7 @@ export function createRequestHandler(
443
442
  case "ping":
444
443
  return respond(id, {});
445
444
  case "tools/list": {
446
- // Zero side-effect gate (ADR-013) + lazy presentation (blueprint v2):
445
+ // Zero side-effect gate (ADR-013) + lazy presentation:
447
446
  // outside Herdr nothing; dormant only the Tier 0 gateway; active the
448
447
  // gateway plus the canonical Tier 1 tools.
449
448
  if (!environmentOk()) return respond(id, { tools: [] });
@@ -526,7 +525,7 @@ function contractWithAppendix(appendix: string): string {
526
525
  * Contract text for prefix-style MCP hosts (e.g. Codex): tools are exposed as
527
526
  * independent `mcp__<namespace>__<canonical>` functions. `namespace` is the
528
527
  * host tool namespace and must be explicit. Presentation is lazy: only the
529
- * gateway is listed until the model activates it (blueprint v2).
528
+ * gateway is listed until the model activates it.
530
529
  */
531
530
  export function buildMcpPrefixedCommunicationContract(namespace: string): string {
532
531
  const [peers, send, close] = HERDR_LINK_COMMUNICATION_TOOLS.map((name) =>
@@ -551,7 +550,7 @@ export function buildMcpPrefixedCommunicationContract(namespace: string): string
551
550
  * Contract text for wrapper-style MCP hosts (e.g. AGY's call_mcp_tool): the
552
551
  * model invokes one native wrapper carrying ServerName/ToolName/Arguments
553
552
  * instead of per-tool functions (PROTOCOL.md §4.6 wrapper form). Both values
554
- * must be explicit. Presentation is lazy (blueprint v2): activate the gateway
553
+ * must be explicit. Presentation is lazy: activate the gateway
555
554
  * first, then address the canonical tools through the same wrapper.
556
555
  */
557
556
  export function buildMcpWrapperCommunicationContract(
package/src/opencode.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Herdr Link OpenCode Runtime adapter v2 — single-gateway presentation.
2
+ * Herdr Link OpenCode Runtime adapter — single-gateway presentation.
3
3
  *
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 (`{}`)
@@ -95,7 +95,7 @@ export const herdrLinkPlugin: Plugin = async () => {
95
95
  description:
96
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. " +
97
97
  'Call once with no arguments {} to activate Herdr Link for this session; the response lists capabilities. ' +
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 — ' +
98
+ 'Then pass action "start" with name and either config_agent or complete kind + args (with to co-locate with a live agent, cwd for a new-tab working directory), 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
99
  'start modes are mutually exclusive and close is only after any final send has returned status "sent", in a later tool step.',
100
100
  args: {
101
101
  action: tool.schema
@@ -120,10 +120,14 @@ export const herdrLinkPlugin: Plugin = async () => {
120
120
  .string()
121
121
  .optional()
122
122
  .describe('New Agent Name; required for action "start".'),
123
- pane: tool.schema
123
+ with: tool.schema
124
124
  .string()
125
125
  .optional()
126
- .describe('Existing pane id; required for action "start".'),
126
+ .describe('Live Agent Name to co-locate with for action "start"; do not combine with cwd.'),
127
+ cwd: tool.schema
128
+ .string()
129
+ .optional()
130
+ .describe('New-tab working directory for action "start".'),
127
131
  config_agent: tool.schema
128
132
  .string()
129
133
  .optional()
@@ -149,7 +153,7 @@ export const herdrLinkPlugin: Plugin = async () => {
149
153
  if (args.action === "start") {
150
154
  const startInput = Object.fromEntries(Object.entries(args).filter(([key]) => key !== "action"));
151
155
  try {
152
- return jsonResult(await startAgent(startInput as unknown as StartAgentInput, { cwd: context.directory }));
156
+ return jsonResult(await startAgent(startInput as unknown as StartAgentInput, { contextDirectory: context.directory }));
153
157
  } catch (error) {
154
158
  failWith(error, "START_FAILED");
155
159
  }