pi-legion 3.0.3 → 4.0.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +263 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +29 -22
  4. package/agents/worker.md +1 -1
  5. package/docs/contracts/display-liveness-v1.md +373 -0
  6. package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
  7. package/docs/contracts/esc-abort-v1.md +4 -4
  8. package/docs/contracts/esc-no-retire-v3.md +2 -2
  9. package/docs/contracts/launch-admission-queue-v1.md +839 -0
  10. package/docs/contracts/status-panel-v1.md +174 -0
  11. package/docs/inprocess-supervisor.md +37 -4
  12. package/docs/legion-v2-contract.md +18 -2
  13. package/docs/performance-2026-10-19.md +199 -0
  14. package/docs/spawn-mode-visibility-v1.md +206 -0
  15. package/docs/vexillatio-contract.md +3 -1
  16. package/index.ts +633 -84
  17. package/package.json +4 -2
  18. package/skills/pi-legion/SKILL.md +2 -3
  19. package/src/agents/agents.ts +17 -10
  20. package/src/agents/overrides.ts +0 -1
  21. package/src/agents/presets.ts +14 -40
  22. package/src/agents/settings.ts +26 -77
  23. package/src/agents/step-model.ts +7 -22
  24. package/src/api.ts +1 -35
  25. package/src/extension/event-bus.ts +80 -8
  26. package/src/extension/legion-commands.ts +43 -17
  27. package/src/extension/legion-doctor.ts +1 -1
  28. package/src/extension/legion-stop.ts +39 -13
  29. package/src/extension/legion-tools.ts +21 -4
  30. package/src/extension/liveness.ts +200 -0
  31. package/src/extension/notify.ts +6 -6
  32. package/src/extension/playbook.ts +6 -6
  33. package/src/extension/recycle.ts +6 -2
  34. package/src/extension/runtime.ts +422 -18
  35. package/src/extension/slash.ts +0 -21
  36. package/src/extension/spawn-render.ts +183 -0
  37. package/src/extension/summary.ts +1 -1
  38. package/src/legion/db.ts +165 -1
  39. package/src/legion/events.ts +81 -16
  40. package/src/legion/nodes.ts +13 -2
  41. package/src/legion/rebuild.ts +7 -1
  42. package/src/legion/usage.ts +58 -4
  43. package/src/runs/orchestrator.ts +1165 -62
  44. package/src/runs/worktree.ts +190 -21
  45. package/src/shared/ignored-kind.ts +57 -0
  46. package/src/shared/progress.ts +11 -0
  47. package/src/shared/session.ts +199 -19
  48. package/src/shared/types.ts +50 -66
  49. package/src/supervisor/admission-queue.ts +516 -0
  50. package/src/supervisor/inprocess-supervisor.ts +108 -33
  51. package/src/supervisor/proc-memory.ts +67 -0
  52. package/src/supervisor/routing-supervisor.ts +58 -1
  53. package/src/tui/live-overlay.ts +14 -0
  54. package/src/tui/session-messages-file.ts +100 -0
  55. package/src/tui/status.ts +103 -21
  56. package/src/tui/tree-view.ts +36 -3
  57. package/src/tui/viz/layout.ts +54 -3
  58. package/src/tui/viz/model.ts +67 -14
  59. package/src/tui/viz/palette.ts +11 -0
  60. package/src/tui/viz/panels.ts +222 -8
  61. package/src/tui/viz/railway.ts +28 -25
  62. package/src/tui/viz/view.ts +110 -23
  63. package/src/backends/legacy/client.ts +0 -638
  64. package/src/backends/legacy/cursor-chat.ts +0 -348
  65. package/src/backends/legacy/runner.ts +0 -101
  66. package/src/backends/legacy/types.ts +0 -138
  67. package/src/runs/kind.ts +0 -260
@@ -1,638 +0,0 @@
1
- /**
2
- * Thin, testable wrapper over the `herdr` CLI.
3
- *
4
- * Measured behaviours this module encodes:
5
- * F1 — `agent start` reports the child's session path
6
- * F7 — non-pi kinds report no session ref (null)
7
- * F11 — clean agent exit is `ctrl+d`, NOT `ctrl+c`
8
- * F16 — `agent_name_taken` while alive; freed on exit
9
- * F19 — `agent_pane_busy` race right after a pane split
10
- * F21 — error JSON arrives on stderr with an empty stdout
11
- * F22 — a missing binary surfaces as a start timeout, not a clear error
12
- */
13
-
14
- import { type AgentKind, ErrorCodes, DEFAULTS, SubagentError } from "../../shared/types.ts";
15
- import type {
16
- AgentInfo,
17
- AgentStartResult,
18
- CommandRunner,
19
- HerdrClient,
20
- HerdrError,
21
- HerdrResult,
22
- PaneInfo,
23
- ReadSource,
24
- TabInfo,
25
- } from "./types.ts";
26
- import { createCommandRunner, type RunnerOptions } from "./runner.ts";
27
-
28
- interface HerdrEnvelope {
29
- id?: string;
30
- result?: unknown;
31
- error?: { code?: string; message?: string; details?: unknown };
32
- }
33
-
34
- /** Map herdr's error codes onto our stable error codes. */
35
- export function mapHerdrErrorCode(
36
- raw: string | undefined,
37
- message: string,
38
- ): string {
39
- const code = (raw ?? "").toLowerCase();
40
- const text = message.toLowerCase();
41
- if (code.includes("pane_busy") || text.includes("not an available shell"))
42
- return ErrorCodes.PANE_BUSY;
43
- if (code.includes("name_taken") || text.includes("already used"))
44
- return ErrorCodes.NAME_TAKEN;
45
- if (code.includes("not_found") || text.includes("not found"))
46
- return ErrorCodes.NOT_FOUND;
47
- if (code.includes("timeout")) return ErrorCodes.START_TIMEOUT;
48
- return raw || "HERDR_ERROR";
49
- }
50
-
51
- function firstJson(text: string): HerdrEnvelope | undefined {
52
- const trimmed = text.trim();
53
- if (!trimmed) return undefined;
54
- try {
55
- return JSON.parse(trimmed) as HerdrEnvelope;
56
- } catch {
57
- /* fall through to line scanning */
58
- }
59
- // herdr may emit a non-JSON banner line before the payload.
60
- const lines = trimmed.split(/\r?\n/);
61
- for (let i = lines.length - 1; i >= 0; i -= 1) {
62
- const line = lines[i]?.trim();
63
- if (!line || (!line.startsWith("{") && !line.startsWith("["))) continue;
64
- try {
65
- return JSON.parse(line) as HerdrEnvelope;
66
- } catch {
67
- /* keep scanning */
68
- }
69
- }
70
- return undefined;
71
- }
72
-
73
- /**
74
- * Turn a raw CLI invocation into a structured result.
75
- *
76
- * F21: success payloads live on stdout, error payloads on stderr.
77
- * We therefore inspect BOTH, preferring the one that parses.
78
- */
79
- export function parseHerdrResponse(
80
- stdout: string,
81
- stderr: string,
82
- code: number,
83
- ): { ok: true; value: unknown } | { ok: false; error: HerdrError } {
84
- const fromStdout = firstJson(stdout);
85
- const fromStderr = firstJson(stderr);
86
-
87
- if (fromStdout?.error) {
88
- return { ok: false, error: toHerdrError(fromStdout.error) };
89
- }
90
- if (fromStderr?.error) {
91
- return { ok: false, error: toHerdrError(fromStderr.error) };
92
- }
93
-
94
- if (code === 0) {
95
- if (fromStdout && "result" in fromStdout)
96
- return { ok: true, value: fromStdout.result };
97
- if (fromStderr && "result" in fromStderr)
98
- return { ok: true, value: fromStderr.result };
99
- if (fromStdout) return { ok: true, value: fromStdout };
100
- if (fromStderr) return { ok: true, value: fromStderr };
101
- return {
102
- ok: false,
103
- error: {
104
- code: "PARSE_ERROR",
105
- message: `no JSON payload (stdout=${stdout.slice(0, 200)})`,
106
- },
107
- };
108
- }
109
-
110
- // Non-zero exit with no JSON error object: surface the raw text.
111
- const detail = (stderr || stdout).trim().slice(0, 500);
112
- return {
113
- ok: false,
114
- error: {
115
- code: code === -1 ? ErrorCodes.HERDR_UNAVAILABLE : "HERDR_ERROR",
116
- message: detail || `herdr exited with code ${code}`,
117
- },
118
- };
119
- }
120
-
121
- function toHerdrError(raw: {
122
- code?: string;
123
- message?: string;
124
- details?: unknown;
125
- }): HerdrError {
126
- const message = raw.message ?? "unknown herdr error";
127
- return {
128
- code: mapHerdrErrorCode(raw.code, message),
129
- message,
130
- details: raw.details,
131
- };
132
- }
133
-
134
- function ok<T>(value: T): HerdrResult<T> {
135
- return { ok: true, value };
136
- }
137
- function err<T>(error: HerdrError): HerdrResult<T> {
138
- return { ok: false, error };
139
- }
140
-
141
- function asRecord(value: unknown): Record<string, unknown> {
142
- return value && typeof value === "object"
143
- ? (value as Record<string, unknown>)
144
- : {};
145
- }
146
-
147
- function str(value: unknown): string | undefined {
148
- return typeof value === "string" ? value : undefined;
149
- }
150
-
151
- // ---------------------------------------------------------------------------
152
- // Payload normalizers
153
- // ---------------------------------------------------------------------------
154
-
155
- function toPaneInfo(raw: unknown): PaneInfo {
156
- const r = asRecord(raw);
157
- return {
158
- pane_id: str(r.pane_id) ?? "",
159
- tab_id: str(r.tab_id) ?? "",
160
- workspace_id: str(r.workspace_id) ?? "",
161
- agent_status: str(r.agent_status),
162
- cwd: (str(r.cwd) ?? null) as string | null,
163
- terminal_title_stripped: str(r.terminal_title_stripped),
164
- };
165
- }
166
-
167
- function toAgentInfo(raw: unknown): AgentInfo {
168
- const r = asRecord(raw);
169
- const session = r.agent_session;
170
- return {
171
- name: (str(r.name) ?? null) as string | null,
172
- pane_id: str(r.pane_id) ?? "",
173
- tab_id: str(r.tab_id) ?? "",
174
- workspace_id: str(r.workspace_id) ?? "",
175
- agent: (str(r.agent) ?? null) as string | null,
176
- agent_status: str(r.agent_status) ?? "unknown",
177
- cwd: (str(r.cwd) ?? null) as string | null,
178
- agent_session:
179
- session && typeof session === "object"
180
- ? {
181
- kind: str(asRecord(session).kind) ?? "",
182
- source: str(asRecord(session).source) ?? "",
183
- value: str(asRecord(session).value) ?? "",
184
- }
185
- : null,
186
- state_labels: asRecord(r.state_labels) as Record<string, string>,
187
- tokens: r.tokens,
188
- };
189
- }
190
-
191
- function toTabInfo(raw: unknown): TabInfo {
192
- const r = asRecord(raw);
193
- return {
194
- tab_id: str(r.tab_id) ?? "",
195
- workspace_id: str(r.workspace_id) ?? "",
196
- label: (str(r.label) ?? null) as string | null,
197
- pane_count: typeof r.pane_count === "number" ? r.pane_count : 0,
198
- };
199
- }
200
-
201
- /**
202
- * herdr wraps an agent in `{agent: {...}}` for some calls and returns it
203
- * directly for others, so both shapes have to be accepted.
204
- */
205
- function agentFromResult(value: unknown): AgentInfo {
206
- const record = asRecord(value);
207
- return toAgentInfo(record.agent ?? value);
208
- }
209
-
210
- /**
211
- * Map `agent start`'s payload to `AgentStartResult`.
212
- * The session path is the resume credential (F1), so it is only included when
213
- * herdr actually reported one — a non-pi kind reports `null` (F7).
214
- */
215
- function toAgentStartResult(
216
- value: unknown,
217
- opts: { name: string; paneId: string },
218
- ): AgentStartResult {
219
- const record = asRecord(value);
220
- const agent = toAgentInfo(record.agent);
221
- // Only the pi session FILE is a `sessionPath` (F1: the resume credential).
222
- // A cursor child reports `agent_session = {kind:"id", source:"herdr:cursor"}`
223
- // — a chat-store id, not a path; surfacing it here would let callers treat
224
- // it as a jsonl to parse.
225
- const sessionPath =
226
- agent.agent_session && agent.agent_session.source === "pi"
227
- ? agent.agent_session.value
228
- : undefined;
229
- return {
230
- name: agent.name ?? opts.name,
231
- paneId: agent.pane_id || opts.paneId,
232
- argv: Array.isArray(record.argv) ? record.argv.map(String) : [],
233
- ...(sessionPath ? { sessionPath } : {}),
234
- agentStatus: agent.agent_status,
235
- };
236
- }
237
-
238
- /**
239
- * herdr wraps a pane in `{pane: {...}}` for some calls and returns it directly
240
- * for others, so both shapes have to be accepted.
241
- */
242
- function paneFromResult(value: unknown): PaneInfo {
243
- const record = asRecord(value);
244
- return toPaneInfo(record.pane ?? value);
245
- }
246
-
247
- /** Map one entry of `pane process-info`'s `foreground_processes`. */
248
- // ---------------------------------------------------------------------------
249
- // Client
250
- // ---------------------------------------------------------------------------
251
-
252
- type Call = <T>(
253
- args: string[],
254
- opts?: { timeoutMs?: number },
255
- ) => Promise<HerdrResult<T>>;
256
-
257
- /** Append `--env K=V` for each entry. Shared by pane split and tab create. */
258
- function pushEnvArgs(
259
- args: string[],
260
- env: Record<string, string> | undefined,
261
- ): void {
262
- for (const [key, value] of Object.entries(env ?? {})) {
263
- args.push("--env", `${key}=${value}`);
264
- }
265
- }
266
-
267
- /**
268
- * The `--cwd` / `--env` / focus trailer shared by `pane split` and `tab create`.
269
- * Both accept the same three options, and keeping them in one place means the
270
- * focus flag can never be forgotten on one path (it must always be explicit).
271
- */
272
- function pushLaunchTrailer(
273
- args: string[],
274
- opts: {
275
- cwd?: string;
276
- env?: Record<string, string>;
277
- focus?: boolean;
278
- },
279
- ): void {
280
- if (opts.cwd) args.push("--cwd", opts.cwd);
281
- pushEnvArgs(args, opts.env);
282
- args.push(opts.focus ? "--focus" : "--no-focus");
283
- }
284
-
285
- function paneSplitCall(
286
- call: Call,
287
- opts: {
288
- target?: string;
289
- current?: boolean;
290
- direction: "right" | "down";
291
- cwd?: string;
292
- env?: Record<string, string>;
293
- focus?: boolean;
294
- },
295
- ): Promise<HerdrResult<PaneInfo>> {
296
- return call<Record<string, unknown>>(paneSplitArgs(opts)).then((res) =>
297
- res.ok ? ok(paneFromResult(res.value)) : res,
298
- );
299
- }
300
-
301
- /** Build the `pane split` argv (extracted so the API factory stays flat). */
302
- function paneSplitArgs(opts: {
303
- target?: string;
304
- current?: boolean;
305
- direction: "right" | "down";
306
- cwd?: string;
307
- env?: Record<string, string>;
308
- focus?: boolean;
309
- }): string[] {
310
- const args = ["pane", "split"];
311
- if (opts.current) args.push("--current");
312
- else if (opts.target) args.push(opts.target);
313
- args.push("--direction", opts.direction);
314
- pushLaunchTrailer(args, opts);
315
- return args;
316
- }
317
-
318
- async function paneReadCall(
319
- runner: CommandRunner,
320
- paneId: string,
321
- opts: { source?: ReadSource; lines?: number },
322
- ): Promise<HerdrResult<string>> {
323
- const args = ["pane", "read", paneId];
324
- if (opts.source) args.push("--source", opts.source);
325
- if (opts.lines) args.push("--lines", String(opts.lines));
326
- // F6: `pane read` emits PLAIN TEXT, not JSON. An explicit per-call cap so
327
- // the call cannot hang even under a legacy/wedged runner; the runner's own
328
- // `defaultTimeoutMs` (if the caller pinned one, including 0) still wins
329
- // because a shorter of the two applies — pass the default only as the
330
- // per-call value, and let the runner's undefined-vs-set logic decide.
331
- const { stdout, stderr, code } = await runner(args, {
332
- timeoutMs: DEFAULTS.commandTimeoutMs,
333
- });
334
- if (code !== 0 && !stdout)
335
- return err({ code: "HERDR_ERROR", message: stderr.slice(0, 500) });
336
- return ok(stdout);
337
- }
338
-
339
- function paneReportMetadataArgs(opts: {
340
- paneId: string;
341
- source: string;
342
- displayAgent?: string;
343
- title?: string;
344
- tokens?: Record<string, string>;
345
- /** `idle=waiting 2 subagents` — labels a herdr agent_status from this source. */
346
- stateLabel?: { status: string; text: string };
347
- /** Auto-expiry so a crashed reporter leaves no stale label. */
348
- ttlMs?: number;
349
- /** Drop all state-labels set by this source. */
350
- clearStateLabels?: boolean;
351
- }): string[] {
352
- const args = [
353
- "pane",
354
- "report-metadata",
355
- opts.paneId,
356
- "--source",
357
- opts.source,
358
- ];
359
- if (opts.displayAgent) args.push("--display-agent", opts.displayAgent);
360
- if (opts.title) args.push("--title", opts.title);
361
- for (const [k, v] of Object.entries(opts.tokens ?? {}))
362
- args.push("--token", `${k}=${v}`);
363
- if (opts.stateLabel)
364
- args.push("--state-label", `${opts.stateLabel.status}=${opts.stateLabel.text}`);
365
- if (opts.clearStateLabels) args.push("--clear-state-labels");
366
- if (opts.ttlMs !== undefined) args.push("--ttl-ms", String(opts.ttlMs));
367
- return args;
368
- }
369
-
370
- function createPaneApi(
371
- call: Call,
372
- runner: CommandRunner,
373
- ): Pick<
374
- HerdrClient,
375
- | "paneSplit"
376
- | "paneClose"
377
- | "paneRead"
378
- | "paneList"
379
- | "paneGet"
380
- | "paneReportMetadata"
381
- > {
382
- return {
383
- paneSplit: (opts) => paneSplitCall(call, opts),
384
-
385
- paneClose: (paneId) => call<void>(["pane", "close", paneId]),
386
-
387
- paneRead: (paneId, opts = {}) => paneReadCall(runner, paneId, opts),
388
-
389
- paneList: () =>
390
- call<Record<string, unknown>>(["pane", "list"]).then((res) => {
391
- if (!res.ok) return res;
392
- const panes = asRecord(res.value).panes;
393
- return ok(Array.isArray(panes) ? panes.map(toPaneInfo) : []);
394
- }),
395
-
396
- paneGet: (paneId) =>
397
- call<Record<string, unknown>>(["pane", "get", paneId]).then((res) =>
398
- res.ok ? ok(paneFromResult(res.value)) : res,
399
- ),
400
-
401
- paneReportMetadata: (opts) => call<void>(paneReportMetadataArgs(opts)),
402
- };
403
- }
404
-
405
- function createTabApi(
406
- call: Call,
407
- ): Pick<HerdrClient, "tabCreate" | "tabClose" | "tabList"> {
408
- return {
409
- async tabCreate(opts) {
410
- const args = ["tab", "create"];
411
- if (opts.workspaceId) args.push("--workspace", opts.workspaceId);
412
- if (opts.label) args.push("--label", opts.label);
413
- pushLaunchTrailer(args, opts);
414
-
415
- const res = await call<Record<string, unknown>>(args);
416
- if (!res.ok) return res;
417
- const value = asRecord(res.value);
418
- const rootPane = asRecord(value.root_pane);
419
- return ok({
420
- tab: toTabInfo(value.tab),
421
- rootPaneId: str(rootPane.pane_id) ?? "",
422
- });
423
- },
424
-
425
- tabClose(tabId) {
426
- return call<void>(["tab", "close", tabId]);
427
- },
428
-
429
-
430
- async tabList(workspaceId) {
431
- const args = ["tab", "list"];
432
- if (workspaceId) args.push("--workspace", workspaceId);
433
- const res = await call<Record<string, unknown>>(args);
434
- if (!res.ok) return res;
435
- const tabs = asRecord(res.value).tabs;
436
- return ok(Array.isArray(tabs) ? tabs.map(toTabInfo) : []);
437
- },
438
- };
439
- }
440
-
441
- function agentStartArgs(opts: {
442
- name: string;
443
- kind: AgentKind;
444
- paneId: string;
445
- args?: string[];
446
- timeoutMs?: number;
447
- }): string[] {
448
- const args = [
449
- "agent",
450
- "start",
451
- opts.name,
452
- "--kind",
453
- opts.kind,
454
- "--pane",
455
- opts.paneId,
456
- ];
457
- if (opts.timeoutMs) args.push("--timeout", String(opts.timeoutMs));
458
- if (opts.args?.length) args.push("--", ...opts.args);
459
- return args;
460
- }
461
-
462
- function agentPromptArgs(
463
- target: string,
464
- text: string,
465
- opts: { wait?: boolean; timeoutMs?: number },
466
- ): string[] {
467
- const args = ["agent", "prompt", target, text];
468
- if (opts.wait) args.push("--wait");
469
- if (opts.timeoutMs) args.push("--timeout", String(opts.timeoutMs));
470
- return args;
471
- }
472
-
473
- function agentWaitArgs(
474
- target: string,
475
- opts: { until?: string[]; timeoutMs?: number },
476
- ): string[] {
477
- const args = ["agent", "wait", target];
478
- for (const state of opts.until ?? []) args.push("--until", state);
479
- if (opts.timeoutMs) args.push("--timeout", String(opts.timeoutMs));
480
- return args;
481
- }
482
-
483
- /** `agent list` -> a typed array, tolerating a non-array payload. */
484
- function agentListFrom(res: HerdrResult<Record<string, unknown>>) {
485
- if (!res.ok) return res;
486
- const agents = asRecord(res.value).agents;
487
- return ok(Array.isArray(agents) ? agents.map(toAgentInfo) : []);
488
- }
489
-
490
- function createAgentApi(
491
- call: Call,
492
- ): Pick<
493
- HerdrClient,
494
- | "agentStart"
495
- | "agentPrompt"
496
- | "agentGet"
497
- | "agentList"
498
- | "agentSendKeys"
499
- | "agentWait"
500
- > {
501
- return {
502
- agentStart: (opts) =>
503
- call<Record<string, unknown>>(agentStartArgs(opts), {
504
- timeoutMs: (opts.timeoutMs ?? 45_000) + 15_000,
505
- }).then((res) =>
506
- res.ok ? ok(toAgentStartResult(res.value, opts)) : res,
507
- ),
508
-
509
- agentPrompt: (target, text, opts = {}) =>
510
- call<Record<string, unknown>>(agentPromptArgs(target, text, opts), {
511
- timeoutMs: (opts.timeoutMs ?? 0) + 20_000,
512
- }).then((res) => (res.ok ? ok(agentFromResult(res.value)) : res)),
513
-
514
- agentGet: (target) =>
515
- call<Record<string, unknown>>(["agent", "get", target]).then((res) =>
516
- res.ok ? ok(agentFromResult(res.value)) : res,
517
- ),
518
-
519
- agentList: () =>
520
- call<Record<string, unknown>>(["agent", "list"]).then(agentListFrom),
521
-
522
- agentSendKeys: (target, ...keys) =>
523
- call<void>(["agent", "send-keys", target, ...keys]),
524
-
525
- agentWait: (target, opts = {}) =>
526
- call<void>(agentWaitArgs(target, opts), {
527
- timeoutMs: (opts.timeoutMs ?? 0) + 10_000,
528
- }),
529
- };
530
- }
531
-
532
- function createMetaApi(
533
- runner: CommandRunner,
534
- ): Pick<HerdrClient, "available" | "integrationStatus" | "integrationInstall"> {
535
- return {
536
- async available() {
537
- try {
538
- const res = await runner(["--version"], { timeoutMs: 5_000 });
539
- return res.code === 0;
540
- } catch {
541
- return false;
542
- }
543
- },
544
-
545
- async integrationStatus(target: string) {
546
- // `integration status` prints human lines ("name: state (path)"),
547
- // not a JSON envelope — so parseHerdrResponse's JSON path cannot be
548
- // used; the raw runner output is matched directly. Unparseable or
549
- // missing line → null ("cannot tell"), never an error: the caller
550
- // treats null as "skip the guard" rather than fail the launch.
551
- const res = await runner(["integration", "status"], {
552
- timeoutMs: 5_000,
553
- });
554
- if (res.code !== 0) return ok(null);
555
- const line = res.stdout
556
- .split(/\r?\n/)
557
- .find((l) => l.trimStart().startsWith(`${target}:`));
558
- if (!line) return ok(null);
559
- return ok(line.slice(line.indexOf(":") + 1).trim());
560
- },
561
-
562
- async integrationInstall(target: string) {
563
- const res = await runner(["integration", "install", target], {
564
- timeoutMs: 15_000,
565
- });
566
- if (res.code !== 0) {
567
- return err({
568
- code: "HERDR_ERROR",
569
- message:
570
- (res.stderr || res.stdout).trim().slice(0, 300) ||
571
- `herdr integration install ${target} failed`,
572
- });
573
- }
574
- return ok((res.stdout || res.stderr).trim());
575
- },
576
- };
577
- }
578
-
579
- export interface CreateHerdrClientOptions extends RunnerOptions {}
580
-
581
- /**
582
- * Create a herdr client.
583
- *
584
- * `options` is forwarded to `createCommandRunner`, so `bin`/`env` override as
585
- * before. When the caller does not pin `defaultTimeoutMs`, the default
586
- * command timeout (`DEFAULTS.commandTimeoutMs`) applies to EVERY invocation
587
- * that does not carry its own timeout — the wedge-proofing described on
588
- * `DEFAULTS.commandTimeoutMs`. A custom `defaultTimeoutMs` of `0` keeps the
589
- * old unbounded behaviour for tests that drive a fake runner.
590
- *
591
- * Back-compat: passing a `CommandRunner` directly (the pre-timeout signature)
592
- * is still accepted and bypasses `createCommandRunner` entirely.
593
- */
594
- export function createHerdrClient(
595
- options: CreateHerdrClientOptions | CommandRunner = {},
596
- ): HerdrClient {
597
- const legacyRunner =
598
- typeof options === "function" ? options : undefined;
599
- const opts: CreateHerdrClientOptions =
600
- typeof options === "function" ? {} : options;
601
- const runner =
602
- legacyRunner ??
603
- createCommandRunner({
604
- ...opts,
605
- // A caller that pins `defaultTimeoutMs` (even to 0 = unbounded) knows
606
- // what it is doing; otherwise wedge-proof every short control call.
607
- defaultTimeoutMs:
608
- opts.defaultTimeoutMs ?? DEFAULTS.commandTimeoutMs,
609
- });
610
- const call: Call = async <T>(
611
- args: string[],
612
- opts: { timeoutMs?: number } = {},
613
- ): Promise<HerdrResult<T>> => {
614
- const { stdout, stderr, code } = await runner(args, opts);
615
- const parsed = parseHerdrResponse(stdout, stderr, code);
616
- return parsed.ok ? ok(parsed.value as T) : err(parsed.error);
617
- };
618
-
619
- return {
620
- ...createPaneApi(call, runner),
621
- ...createTabApi(call),
622
- ...createAgentApi(call),
623
- ...createMetaApi(runner),
624
- };
625
- }
626
-
627
- /** Read a pane's recent output, preferring the unwrapped view (design §1.7). */
628
- export async function readPaneDiagnostic(
629
- client: HerdrClient,
630
- paneId: string,
631
- lines = 60,
632
- source: ReadSource = "recent-unwrapped",
633
- ): Promise<string> {
634
- const res = await client.paneRead(paneId, { source, lines });
635
- return res.ok ? res.value : "";
636
- }
637
-
638
- export { SubagentError };