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/CHANGELOG.md +28 -1
- package/PROTOCOL.md +35 -31
- package/README.md +15 -22
- package/README.zh-CN.md +16 -22
- package/dist/herdr-link.mcp.js +235 -38
- package/dist/herdr-link.opencode.js +228 -31
- package/docs/mcp-wiring.md +11 -14
- package/examples/agent_config.example.json +17 -2
- package/package.json +3 -2
- package/src/herdr.ts +320 -27
- package/src/mcp.ts +17 -18
- package/src/opencode.ts +9 -5
- package/src/pi.ts +34 -7
- package/src/protocol.ts +19 -22
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", "
|
|
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;
|
|
144
|
-
| { mode: "explicit"; name: string;
|
|
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("
|
|
159
|
+
throw startInputError('"name" must be a valid Herdr Agent Name');
|
|
154
160
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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("
|
|
189
|
+
throw startInputError('"config_agent" must be a non-empty string');
|
|
170
190
|
}
|
|
171
|
-
return { mode: "configured", name,
|
|
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("
|
|
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("
|
|
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,
|
|
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, ["
|
|
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
|
-
/**
|
|
299
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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]: `
|
|
83
|
-
[TOOL_SEND]:
|
|
84
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
123
|
+
with: tool.schema
|
|
124
124
|
.string()
|
|
125
125
|
.optional()
|
|
126
|
-
.describe('
|
|
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, {
|
|
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
|
}
|