herdr-link 0.2.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 ADDED
@@ -0,0 +1,535 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import {
4
+ AGENT_ERROR_DETAILS,
5
+ buildEnvelope,
6
+ buildInboundWrapper,
7
+ HerdrLinkError,
8
+ isValidAgentName,
9
+ toAgentState,
10
+ type AgentContext,
11
+ type HerdrLinkEnvelope,
12
+ type LinkErrorCode,
13
+ type PeerDirectory,
14
+ type PeerInfo,
15
+ } from "./protocol.ts";
16
+
17
+ export interface HerdrCommandOutput {
18
+ stdout: string;
19
+ stderr: string;
20
+ }
21
+
22
+ export type HerdrRunner = (file: string, args: string[]) => Promise<HerdrCommandOutput>;
23
+
24
+ export function attachCliOutput(error: Error, stdout: string, stderr: string): void {
25
+ Object.assign(error, { stdout, stderr });
26
+ }
27
+ const defaultHerdrRunner: HerdrRunner = (file, args) =>
28
+ new Promise((resolve, reject) => {
29
+ execFile(file, args, { encoding: "utf8", shell: false }, (error, stdout, stderr) => {
30
+ if (error) {
31
+ attachCliOutput(error, String(stdout), String(stderr));
32
+ reject(error);
33
+ return;
34
+ }
35
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
36
+ });
37
+ });
38
+
39
+ let herdrRunner: HerdrRunner = defaultHerdrRunner;
40
+
41
+
42
+ /** Replace the process runner in tests; passing undefined restores real execFile IO. */
43
+ export function setHerdrRunnerForTests(runner: HerdrRunner | undefined): void {
44
+ herdrRunner = runner ?? defaultHerdrRunner;
45
+ }
46
+
47
+ export function assertHerdrEnvironment(): void {
48
+ if (process.env.HERDR_ENV !== "1") {
49
+ throw new HerdrLinkError("NOT_IN_HERDR", "HERDR_ENV must be 1");
50
+ }
51
+ if (!process.env.HERDR_BIN_PATH) {
52
+ throw new HerdrLinkError("NOT_IN_HERDR", "HERDR_BIN_PATH is missing");
53
+ }
54
+ }
55
+
56
+ function describeError(error: unknown): string {
57
+ if (error instanceof Error) return error.message;
58
+ if (typeof error === "string") return error;
59
+ try {
60
+ return JSON.stringify(error);
61
+ } catch {
62
+ return String(error);
63
+ }
64
+ }
65
+
66
+ function errorDetail(error: unknown): string {
67
+ if (error instanceof HerdrLinkError) {
68
+ const prefix = `${error.code}: `;
69
+ return error.message.startsWith(prefix) ? error.message.slice(prefix.length) : error.message;
70
+ }
71
+ return describeError(error);
72
+ }
73
+
74
+ function operationError(error: unknown, code: LinkErrorCode): HerdrLinkError {
75
+ return new HerdrLinkError(code, errorDetail(error));
76
+ }
77
+
78
+ /** Structured Herdr CLI application rejection; operation adapters map it to their stable code. */
79
+ class HerdrCliError extends Error {
80
+ readonly cliCode: string;
81
+
82
+ constructor(cliCode: string, detail: string) {
83
+ super(detail);
84
+ this.name = "HerdrCliError";
85
+ this.cliCode = cliCode;
86
+ }
87
+ }
88
+
89
+ async function runFor(args: string[], failureCode: LinkErrorCode): Promise<unknown> {
90
+ assertHerdrEnvironment();
91
+ try {
92
+ return await runHerdr(args);
93
+ } catch (error) {
94
+ if (error instanceof HerdrCliError) throw operationError(error, failureCode);
95
+ // Already-classified Link errors, including NOT_IN_HERDR, pass through
96
+ // without being re-labelled by the operation-specific fallback code.
97
+ if (error instanceof HerdrLinkError) throw error;
98
+ throw operationError(error, failureCode);
99
+ }
100
+ }
101
+
102
+ const CLI_ERROR_CODE_MAP: Record<string, LinkErrorCode> = {
103
+ agent_not_found: "PEER_NOT_FOUND",
104
+ not_in_herdr: "NOT_IN_HERDR",
105
+ };
106
+
107
+ function classifyCliError(error: unknown): HerdrLinkError | HerdrCliError | undefined {
108
+ if (typeof error !== "object" || error === null) return undefined;
109
+ const commandError = error as { stdout?: unknown; stderr?: unknown };
110
+
111
+ for (const output of [commandError.stdout, commandError.stderr]) {
112
+ if (typeof output !== "string") continue;
113
+
114
+ let payload: unknown;
115
+ try {
116
+ payload = JSON.parse(output);
117
+ } catch {
118
+ continue;
119
+ }
120
+
121
+ const errorPayload = asRecord(asRecord(payload)?.error);
122
+ if (!errorPayload) continue;
123
+ const cliCode = errorPayload.code;
124
+ if (typeof cliCode !== "string" || cliCode.length === 0) continue;
125
+
126
+ const cliMessage = errorPayload.message;
127
+ const detail = typeof cliMessage === "string" && cliMessage.length > 0 ? `${cliCode}: ${cliMessage}` : cliCode;
128
+ const mappedCode = CLI_ERROR_CODE_MAP[cliCode];
129
+ return mappedCode ? new HerdrLinkError(mappedCode, detail) : new HerdrCliError(cliCode, detail);
130
+ }
131
+
132
+ return undefined;
133
+ }
134
+
135
+ export async function runHerdr(args: string[]): Promise<unknown> {
136
+ assertHerdrEnvironment();
137
+ const binary = process.env.HERDR_BIN_PATH;
138
+
139
+ try {
140
+ const output = await herdrRunner(binary as string, args);
141
+ const parsed = JSON.parse(output.stdout);
142
+ const cliError = classifyCliError(output);
143
+ if (cliError) throw cliError;
144
+ return parsed;
145
+ } catch (error) {
146
+ if (error instanceof HerdrLinkError || error instanceof HerdrCliError) throw error;
147
+ const cliError = classifyCliError(error);
148
+ if (cliError) throw cliError;
149
+ // Stale/deleted binary, transport failure, or invalid JSON all mean the
150
+ // Herdr environment itself is unusable (NOT_IN_HERDR), not an operation
151
+ // failure of the calling tool.
152
+ throw new HerdrLinkError("NOT_IN_HERDR", `Herdr command or JSON response failed: ${describeError(error)}`);
153
+ }
154
+ }
155
+
156
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
157
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
158
+ return value as Record<string, unknown>;
159
+ }
160
+
161
+ function agentRecord(value: unknown): Record<string, unknown> | undefined {
162
+ const root = asRecord(value);
163
+ if (!root) return undefined;
164
+
165
+ const result = asRecord(root.result);
166
+ const nestedAgent = asRecord(result?.agent) ?? asRecord(root.agent);
167
+ if (nestedAgent) return nestedAgent;
168
+
169
+ if (typeof result?.name === "string" || typeof result?.pane_id === "string") return result;
170
+ if (typeof root.name === "string" || typeof root.pane_id === "string") return root;
171
+ return undefined;
172
+ }
173
+
174
+ function agentList(value: unknown): unknown[] {
175
+ const root = asRecord(value);
176
+ const result = asRecord(root?.result);
177
+ const agents = result?.agents ?? root?.agents;
178
+ return Array.isArray(agents) ? agents : [];
179
+ }
180
+
181
+ /* ------------------------------------------------------------------ *
182
+ * Live record readers (blueprint v2)
183
+ *
184
+ * Every communication call resolves fresh records from Herdr. Ambient
185
+ * environment values such as HERDR_WORKSPACE_ID are never consulted:
186
+ * workspace identity comes only from the live CLI response.
187
+ * ------------------------------------------------------------------ */
188
+
189
+ function nonEmptyString(value: unknown): string | undefined {
190
+ return typeof value === "string" && value.length > 0 ? value : undefined;
191
+ }
192
+
193
+ function validAgentNameValue(value: unknown): string | undefined {
194
+ const name = nonEmptyString(value);
195
+ return name !== undefined && isValidAgentName(name) ? name : undefined;
196
+ }
197
+
198
+ interface LiveRecordFields {
199
+ name?: string;
200
+ workspace_id?: string;
201
+ pane_id?: string;
202
+ live?: boolean;
203
+ }
204
+
205
+ function readLiveRecord(value: unknown): LiveRecordFields {
206
+ const agent = agentRecord(value);
207
+ return {
208
+ name: validAgentNameValue(agent?.name),
209
+ workspace_id: nonEmptyString(agent?.workspace_id),
210
+ pane_id: nonEmptyString(agent?.pane_id),
211
+ live: typeof agent?.live === "boolean" ? agent.live : undefined,
212
+ };
213
+ }
214
+
215
+ function readStatus(value: unknown): ReturnType<typeof toAgentState> {
216
+ const agent = agentRecord(value);
217
+ return toAgentState(agent?.agent_status ?? agent?.status);
218
+ }
219
+
220
+ /** Entries may opt out explicitly; presence in `agent list` is otherwise live. */
221
+ function isExcludedEntry(value: unknown): boolean {
222
+ return agentRecord(value)?.live === false;
223
+ }
224
+
225
+ /* ------------------------------------------------------------------ *
226
+ * Self identity bootstrap (PROTOCOL.md §6.3)
227
+ * ------------------------------------------------------------------ */
228
+
229
+ const GENERATED_NAME_PREFIX = "hl-";
230
+
231
+ /** Total rename attempts per bootstrap; collisions regenerate within it. */
232
+ const MAX_GENERATED_NAME_ATTEMPTS = 3;
233
+
234
+ /** Fixed sanitized detail; raw pane ids and CLI diagnostics stay internal. */
235
+ const SELF_BOOTSTRAP_FAILED_DETAIL =
236
+ "Herdr Link could not establish a stable Agent Name";
237
+
238
+ function selfUnnamed(detail?: string): HerdrLinkError {
239
+ return new HerdrLinkError("SELF_UNNAMED", detail ?? SELF_BOOTSTRAP_FAILED_DETAIL);
240
+ }
241
+
242
+ /** Generated Link-owned Agent Name (`hl-` + hex), valid per `[a-z][a-z0-9_-]{0,31}`. */
243
+ function generateAgentName(): string {
244
+ return `${GENERATED_NAME_PREFIX}${randomBytes(4).toString("hex")}`;
245
+ }
246
+
247
+ /** The occupant's stable Agent Name, or undefined when absent or not live. */
248
+ function stableName(record: LiveRecordFields): string | undefined {
249
+ return record.live === false ? undefined : record.name;
250
+ }
251
+
252
+ /** Bounded detection-readiness budget: Herdr may not yet have noticed a
253
+ * freshly launched pane occupant when the adapter boots (PROTOCOL.md §6.3). */
254
+ const SELF_PROBE_ATTEMPTS = 3;
255
+ const SELF_PROBE_DELAY_MS = 100;
256
+
257
+ const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
258
+
259
+ async function fetchSelfRecord(pane: string): Promise<unknown> {
260
+ for (let attempt = 1; ; attempt += 1) {
261
+ try {
262
+ return await runFor(["agent", "get", pane], "SELF_UNNAMED");
263
+ } catch (error) {
264
+ // The caller pane is not (yet) a named agent target; keep self-resolution
265
+ // failures in the SELF_UNNAMED vocabulary. The short bounded readiness
266
+ // retry is a best-effort bridge over transient launch races — not a
267
+ // correctness guarantee and never a substitute for the authoritative
268
+ // getSelfContext() fallback. Transport and other classified failures are
269
+ // never retried.
270
+ const notDetectedYet =
271
+ error instanceof HerdrLinkError && error.code === "PEER_NOT_FOUND";
272
+ if (notDetectedYet && attempt < SELF_PROBE_ATTEMPTS) {
273
+ await sleepMs(SELF_PROBE_DELAY_MS);
274
+ continue;
275
+ }
276
+ if (notDetectedYet) {
277
+ throw selfUnnamed(errorDetail(error));
278
+ }
279
+ throw error;
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Self identity bootstrap (PROTOCOL.md §6.3): guarantees the current pane
286
+ * occupant has a stable Agent Name and returns it.
287
+ *
288
+ * - A validly-named occupant is returned unchanged; user-assigned names are
289
+ * never rewritten.
290
+ * - A live-but-unnamed occupant receives one generated `hl-*` name via
291
+ * `agent rename`, confirmed by re-reading the authoritative live record.
292
+ * - Undetected occupants get a tiny bounded readiness retry inside the initial
293
+ * probe; `agent_name_taken` regenerates within a bounded collision budget —
294
+ * these two budgets are the only internal retries. Any other failure
295
+ * collapses to SELF_UNNAMED with a fixed sanitized detail, except
296
+ * NOT_IN_HERDR which keeps its own code.
297
+ * - Concurrent callers share one in-flight sequence, so a startup bootstrap
298
+ * can never race a communication-path fallback into double-renaming. The
299
+ * guard clears on settle: nothing is cached or persisted between calls,
300
+ * and Herdr remains the lifecycle authority.
301
+ */
302
+ let bootstrapInFlight: Promise<string> | undefined;
303
+
304
+ export function ensureSelfName(): Promise<string> {
305
+ bootstrapInFlight ??= ensureSelfNameFlow().finally(() => {
306
+ bootstrapInFlight = undefined;
307
+ });
308
+ return bootstrapInFlight;
309
+ }
310
+
311
+ /** @internal Test seam only: clears the in-flight bootstrap guard so
312
+ * sequential subtests start from a clean slate. Production never needs this —
313
+ * the guard clears itself on settle. */
314
+ export function resetSelfBootstrapForTests(): void {
315
+ bootstrapInFlight = undefined;
316
+ }
317
+
318
+ async function ensureSelfNameFlow(): Promise<string> {
319
+ assertHerdrEnvironment();
320
+ const pane = process.env.HERDR_PANE_ID;
321
+ if (!pane) {
322
+ throw selfUnnamed("HERDR_PANE_ID is missing");
323
+ }
324
+ return establishSelfName(pane, readLiveRecord(await fetchSelfRecord(pane)));
325
+ }
326
+
327
+ /** Core §6.3 sequence for an already-fetched live record of `pane`. */
328
+ async function establishSelfName(pane: string, record: LiveRecordFields): Promise<string> {
329
+ const existing = stableName(record);
330
+ if (existing) return existing;
331
+ if (record.live === false) {
332
+ // Occupant not recognized as live: there is nothing to rename.
333
+ throw selfUnnamed();
334
+ }
335
+
336
+ for (let attempt = 0; attempt < MAX_GENERATED_NAME_ATTEMPTS; attempt += 1) {
337
+ try {
338
+ await runHerdr(["agent", "rename", pane, generateAgentName()]);
339
+ } catch (error) {
340
+ if (error instanceof HerdrCliError && error.cliCode === "agent_name_taken") {
341
+ continue; // collision: regenerate within the bounded budget
342
+ }
343
+ if (error instanceof HerdrLinkError && error.code === "NOT_IN_HERDR") {
344
+ throw error; // environment/transport failures keep their classified code
345
+ }
346
+ throw selfUnnamed();
347
+ }
348
+ // Confirm against a fresh authoritative read; never trust the rename echo.
349
+ const confirmed = stableName(readLiveRecord(await fetchSelfRecord(pane)));
350
+ if (confirmed) return confirmed;
351
+ throw selfUnnamed();
352
+ }
353
+ // Collision budget exhausted without a confirmed name.
354
+ throw selfUnnamed();
355
+ }
356
+
357
+ /**
358
+ * Resolves the caller's own live context via `HERDR_PANE_ID -> agent get`.
359
+ * Fresh on every call: name/workspace_id/pane_id/agent_status come from the
360
+ * current live record, never from cache or ambient environment. A live but
361
+ * unnamed occupant triggers the §6.3 self-bootstrap fallback, providing a
362
+ * fresh resolution when the adapter's init-time bootstrap did not establish
363
+ * identity; context is then rebuilt from a fresh authoritative read.
364
+ */
365
+ export async function getSelfContext(): Promise<AgentContext> {
366
+ assertHerdrEnvironment();
367
+ const pane = process.env.HERDR_PANE_ID;
368
+ if (!pane) {
369
+ throw selfUnnamed("HERDR_PANE_ID is missing");
370
+ }
371
+
372
+ let response = await fetchSelfRecord(pane);
373
+ let record = readLiveRecord(response);
374
+ if (!stableName(record) && record.live !== false) {
375
+ // Route through the guarded entry: a concurrent startup bootstrap is
376
+ // awaited, never duplicated (single rename per moment); state is then
377
+ // re-read fresh below after it settles.
378
+ await ensureSelfName();
379
+ response = await fetchSelfRecord(pane);
380
+ record = readLiveRecord(response);
381
+ }
382
+
383
+ const name = stableName(record);
384
+ if (!name) {
385
+ throw selfUnnamed("current Herdr agent has no valid name");
386
+ }
387
+ return {
388
+ name,
389
+ workspace_id: record.workspace_id ?? "",
390
+ pane_id: record.pane_id ?? pane,
391
+ agent_status: readStatus(response),
392
+ };
393
+ }
394
+
395
+ /** Compat wrapper returning only the live self name; new callers should use {@link getSelfContext}. */
396
+ export async function getSelf(): Promise<string> {
397
+ return (await getSelfContext()).name;
398
+ }
399
+
400
+ /**
401
+ * Resolves a target agent's live context by name, fresh on every call.
402
+ * Invalid, nonexistent, or unnamed targets are PEER_NOT_FOUND.
403
+ */
404
+ export async function getAgentContext(name: string): Promise<AgentContext> {
405
+ assertHerdrEnvironment();
406
+ if (!isValidAgentName(name)) {
407
+ throw new HerdrLinkError("PEER_NOT_FOUND", `target agent name "${name}" is invalid`);
408
+ }
409
+
410
+ const response = await runFor(["agent", "get", name], "PEER_NOT_FOUND");
411
+ const record = readLiveRecord(response);
412
+ if (record.live === false || !record.name || record.name !== name) {
413
+ throw new HerdrLinkError("PEER_NOT_FOUND", `target agent "${name}" has no valid live record`);
414
+ }
415
+ return {
416
+ name: record.name,
417
+ workspace_id: record.workspace_id ?? "",
418
+ pane_id: record.pane_id ?? "",
419
+ agent_status: readStatus(response),
420
+ };
421
+ }
422
+
423
+ /**
424
+ * Same-workspace guard. Workspace ids must be present on both live records
425
+ * and identical; anything else (cross-workspace, unreported workspace) fails
426
+ * closed with the privacy-preserving peer-not-found wording so callers
427
+ * cannot distinguish foreign agents from nonexistent ones.
428
+ */
429
+ function assertSameWorkspace(self: AgentContext, target: AgentContext): void {
430
+ if (
431
+ self.workspace_id === "" ||
432
+ target.workspace_id === "" ||
433
+ self.workspace_id !== target.workspace_id
434
+ ) {
435
+ throw new HerdrLinkError("PEER_NOT_FOUND", AGENT_ERROR_DETAILS.PEER_NOT_FOUND);
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Instant same-workspace peer directory: `{ self: { name, state }, peers:
441
+ * [{ name, state }] }`. Only validly-named, live agents whose authoritative
442
+ * workspace equals the caller's live workspace are listed; self excluded;
443
+ * no topology ids exposed. Generated fresh on every call.
444
+ */
445
+ export async function listPeers(): Promise<PeerDirectory> {
446
+ const self = await getSelfContext();
447
+ const response = await runFor(["agent", "list"], "NOT_IN_HERDR");
448
+
449
+ const peers: PeerInfo[] = [];
450
+ const seen = new Set<string>();
451
+ for (const entry of agentList(response)) {
452
+ const record = readLiveRecord(entry);
453
+ if (!record.name || seen.has(record.name)) continue;
454
+ seen.add(record.name);
455
+ if (record.name === self.name) continue;
456
+ if (self.workspace_id === "" || record.workspace_id !== self.workspace_id) continue;
457
+ if (isExcludedEntry(entry)) continue;
458
+ peers.push({ name: record.name, state: readStatus(entry) });
459
+ }
460
+
461
+ return { self: { name: self.name, state: self.agent_status }, peers };
462
+ }
463
+
464
+ /**
465
+ * Sends a validated herdr-link/1 envelope to a same-workspace live peer.
466
+ * Self and target are resolved live on every call; no state checks, no
467
+ * requirement to consult peers first, no retry. The payload delivered to
468
+ * `agent prompt` is the self-describing inbound wrapper; the outer wrapper
469
+ * never enters the envelope.
470
+ */
471
+ export async function sendMessage(
472
+ to: string,
473
+ message: string,
474
+ reply_to?: string,
475
+ ): Promise<{ status: "sent"; id: string; to: string }> {
476
+ const self = await getSelfContext();
477
+ const target = await getAgentContext(to);
478
+ assertSameWorkspace(self, target);
479
+
480
+ const envelope: HerdrLinkEnvelope = buildEnvelope({
481
+ from: self.name,
482
+ to: target.name,
483
+ message,
484
+ reply_to,
485
+ });
486
+
487
+ await runFor(["agent", "prompt", target.name, buildInboundWrapper(envelope)], "SEND_FAILED");
488
+ return { status: "sent", id: envelope.id, to: target.name };
489
+ }
490
+
491
+ /**
492
+ * Resolves only the caller's authoritative workspace for close. Close is an
493
+ * explicit target-name operation and, per PROTOCOL.md §6.2, does not require
494
+ * the caller occupant itself to have a stable Agent Name; it still needs a
495
+ * live workspace record so the same-workspace guard can fail closed.
496
+ */
497
+ async function getSelfWorkspaceId(): Promise<string> {
498
+ assertHerdrEnvironment();
499
+ const pane = process.env.HERDR_PANE_ID;
500
+ if (!pane) {
501
+ throw new HerdrLinkError("PEER_NOT_FOUND", AGENT_ERROR_DETAILS.PEER_NOT_FOUND);
502
+ }
503
+ const response = await runFor(["agent", "get", pane], "NOT_IN_HERDR");
504
+ const record = readLiveRecord(response);
505
+ if (record.live === false || !record.workspace_id) {
506
+ throw new HerdrLinkError("PEER_NOT_FOUND", AGENT_ERROR_DETAILS.PEER_NOT_FOUND);
507
+ }
508
+ return record.workspace_id;
509
+ }
510
+
511
+ /**
512
+ * Closes the pane currently hosting a named same-workspace live agent.
513
+ * The target is resolved fresh on every call; the caller only contributes its
514
+ * authoritative workspace and need not itself be named. No caching, focused-
515
+ * pane fallback, state checks, or retry is allowed.
516
+ */
517
+ export async function closeAgentPane(agentName: string): Promise<{ status: "closed"; agent: string }> {
518
+ assertHerdrEnvironment();
519
+ if (!isValidAgentName(agentName)) {
520
+ throw new HerdrLinkError("PEER_NOT_FOUND", `target agent name "${agentName}" is invalid`);
521
+ }
522
+
523
+ const selfWorkspaceId = await getSelfWorkspaceId();
524
+ const target = await getAgentContext(agentName);
525
+ if (target.workspace_id === "" || target.workspace_id !== selfWorkspaceId) {
526
+ throw new HerdrLinkError("PEER_NOT_FOUND", AGENT_ERROR_DETAILS.PEER_NOT_FOUND);
527
+ }
528
+
529
+ if (!target.pane_id) {
530
+ throw new HerdrLinkError("PEER_NOT_FOUND", `target agent "${agentName}" has no current pane`);
531
+ }
532
+
533
+ await runFor(["pane", "close", target.pane_id], "CLOSE_FAILED");
534
+ return { status: "closed", agent: target.name };
535
+ }