pi-subagents 0.55.0 → 0.57.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 (97) hide show
  1. package/CHANGELOG.md +81 -3
  2. package/agents/claude-code-writer.md +15 -0
  3. package/agents/claude-code.md +15 -0
  4. package/agents/codex-exec-writer.md +15 -0
  5. package/agents/codex-exec.md +15 -0
  6. package/agents/cursor-agent-writer.md +14 -0
  7. package/agents/cursor-agent.md +14 -0
  8. package/docs/agents.md +111 -15
  9. package/docs/configuration.md +37 -0
  10. package/docs/extension-api.md +2 -2
  11. package/docs/models.md +6 -0
  12. package/docs/observability.md +8 -7
  13. package/docs/tool-reference.md +11 -1
  14. package/docs/workflows.md +20 -0
  15. package/package.json +1 -1
  16. package/skills/pi-subagents/references/execution-controls.md +1 -1
  17. package/src/agents/agent-management.ts +13 -5
  18. package/src/agents/agent-refinements.ts +4 -4
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +242 -49
  21. package/src/agents/builtin-names.ts +6 -0
  22. package/src/agents/runtime-agent-registry.ts +8 -2
  23. package/src/api/preflight.ts +22 -3
  24. package/src/extension/config.ts +70 -0
  25. package/src/extension/doctor.ts +3 -3
  26. package/src/extension/index.ts +18 -4
  27. package/src/extension/public-execution.ts +29 -13
  28. package/src/extension/rpc.ts +20 -5
  29. package/src/extension/schemas.ts +9 -3
  30. package/src/extension/tool-description.ts +11 -9
  31. package/src/inspectors/herdr/actions.ts +2 -1
  32. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  33. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  34. package/src/integrations/herdr-status.ts +46 -3
  35. package/src/runs/background/active-async-capacity.ts +77 -10
  36. package/src/runs/background/async-execution.ts +148 -27
  37. package/src/runs/background/async-job-tracker.ts +5 -0
  38. package/src/runs/background/async-resume.ts +3 -0
  39. package/src/runs/background/async-retention.ts +20 -3
  40. package/src/runs/background/async-status.ts +5 -0
  41. package/src/runs/background/chain-root-attachment.ts +15 -1
  42. package/src/runs/background/fleet-view.ts +16 -10
  43. package/src/runs/background/inspect-rpc.ts +8 -8
  44. package/src/runs/background/result-files.ts +27 -14
  45. package/src/runs/background/result-watcher.ts +9 -5
  46. package/src/runs/background/run-status.ts +33 -6
  47. package/src/runs/background/scheduled-runs.ts +7 -1
  48. package/src/runs/background/subagent-runner.ts +254 -49
  49. package/src/runs/background/wait-completions.ts +4 -0
  50. package/src/runs/foreground/execution.ts +107 -16
  51. package/src/runs/foreground/foreground-control.ts +6 -0
  52. package/src/runs/foreground/foreground-history.ts +25 -3
  53. package/src/runs/foreground/subagent-executor.ts +324 -84
  54. package/src/runs/foreground/workflow-detach-reconcile.ts +31 -7
  55. package/src/runs/shared/acceptance.ts +10 -5
  56. package/src/runs/shared/agent-contract.ts +1 -1
  57. package/src/runs/shared/child-protocol.ts +21 -7
  58. package/src/runs/shared/claude-code-adapter.ts +129 -0
  59. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  60. package/src/runs/shared/completion-guard.ts +3 -2
  61. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  62. package/src/runs/shared/dynamic-fanout.ts +1 -1
  63. package/src/runs/shared/extension-bindings.ts +78 -0
  64. package/src/runs/shared/external-cli-contract.ts +167 -0
  65. package/src/runs/shared/external-cli-preflight.ts +122 -0
  66. package/src/runs/shared/external-cli-runner.ts +349 -54
  67. package/src/runs/shared/fast-mode-extension.ts +10 -0
  68. package/src/runs/shared/model-exclusions.ts +59 -8
  69. package/src/runs/shared/model-fallback.ts +25 -8
  70. package/src/runs/shared/mutation-evidence.ts +150 -0
  71. package/src/runs/shared/nested-events.ts +3 -1
  72. package/src/runs/shared/nested-render.ts +2 -2
  73. package/src/runs/shared/parallel-utils.ts +3 -0
  74. package/src/runs/shared/pi-args.ts +47 -0
  75. package/src/runs/shared/process-signal.ts +13 -0
  76. package/src/runs/shared/run-history.ts +21 -1
  77. package/src/runs/shared/structured-output.ts +18 -4
  78. package/src/runs/shared/subagent-prompt-runtime.ts +9 -5
  79. package/src/shared/fork-context.ts +21 -0
  80. package/src/shared/formatters.ts +7 -1
  81. package/src/shared/launch-contract.ts +6 -0
  82. package/src/shared/pruned-fork.ts +450 -0
  83. package/src/shared/session-file-trust.ts +19 -0
  84. package/src/shared/session-tokens.ts +14 -3
  85. package/src/shared/settings.ts +16 -3
  86. package/src/shared/shortcuts.ts +17 -0
  87. package/src/shared/types.ts +166 -9
  88. package/src/shared/workflow-child-permit.ts +116 -0
  89. package/src/slash/delegation-adapters.ts +0 -1
  90. package/src/slash/slash-commands.ts +8 -6
  91. package/src/tui/fleet-status.ts +27 -10
  92. package/src/tui/fleet-transcript.ts +11 -5
  93. package/src/tui/fleet.ts +28 -13
  94. package/src/tui/render.ts +47 -21
  95. package/src/workflows/scripted-workflow.ts +298 -30
  96. package/src/workflows/workflow-child-summary.ts +117 -0
  97. package/src/workflows/workflow-receipt.ts +119 -4
@@ -0,0 +1,78 @@
1
+ export const PI_SUBAGENT_EXTENSION_BINDINGS_ENV = "PI_SUBAGENT_EXTENSION_BINDINGS";
2
+ export const MAX_EXTENSION_BINDING_NAMESPACES = 16;
3
+ export const MAX_EXTENSION_BINDINGS_BYTES = 16 * 1024;
4
+ export const MAX_EXTENSION_BINDINGS_DEPTH = 16;
5
+ export const MAX_EXTENSION_BINDINGS_PROPERTIES = 256;
6
+
7
+ const EXTENSION_BINDING_NAMESPACE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62})\/[1-9][0-9]{0,8}$/;
8
+
9
+ export type ExtensionBindingJson = null | boolean | number | string | ReadonlyArray<ExtensionBindingJson> | { readonly [key: string]: ExtensionBindingJson };
10
+ export type ExtensionBindings = Readonly<Record<string, ExtensionBindingJson>>;
11
+
12
+ export interface NormalizedExtensionBindings {
13
+ value: ExtensionBindings;
14
+ json: string;
15
+ }
16
+
17
+ function canonicalizeJson(value: unknown, path: string, depth: number, seen: Set<object>, propertyCount: { value: number }): ExtensionBindingJson {
18
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
19
+ if (typeof value === "number") {
20
+ if (!Number.isFinite(value)) throw new Error(`${path} must contain only finite JSON numbers.`);
21
+ return value;
22
+ }
23
+ if (typeof value !== "object") throw new Error(`${path} must contain only plain JSON values.`);
24
+ if (depth > MAX_EXTENSION_BINDINGS_DEPTH) throw new Error(`${path} exceeds the maximum nesting depth of ${MAX_EXTENSION_BINDINGS_DEPTH}.`);
25
+ if (seen.has(value)) throw new Error(`${path} must not contain cycles.`);
26
+ seen.add(value);
27
+ try {
28
+ if (Array.isArray(value)) {
29
+ const output: ExtensionBindingJson[] = [];
30
+ for (let index = 0; index < value.length; index++) {
31
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw new Error(`${path} must not contain sparse arrays.`);
32
+ output.push(canonicalizeJson(value[index], `${path}[${index}]`, depth + 1, seen, propertyCount));
33
+ }
34
+ return Object.freeze(output);
35
+ }
36
+ const prototype = Object.getPrototypeOf(value);
37
+ if (prototype !== Object.prototype && prototype !== null) throw new Error(`${path} must contain only plain JSON objects.`);
38
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(`${path} must not contain symbol keys.`);
39
+ const descriptors = Object.getOwnPropertyDescriptors(value);
40
+ const output: Record<string, ExtensionBindingJson> = {};
41
+ for (const key of Object.keys(descriptors).sort()) {
42
+ const descriptor = descriptors[key]!;
43
+ if (!descriptor.enumerable || !("value" in descriptor)) throw new Error(`${path}.${key} must be an enumerable data property.`);
44
+ propertyCount.value += 1;
45
+ if (propertyCount.value > MAX_EXTENSION_BINDINGS_PROPERTIES) throw new Error(`extensionBindings exceeds ${MAX_EXTENSION_BINDINGS_PROPERTIES} total properties.`);
46
+ Object.defineProperty(output, key, { value: canonicalizeJson(descriptor.value, `${path}.${key}`, depth + 1, seen, propertyCount), enumerable: true, writable: false, configurable: false });
47
+ }
48
+ return Object.freeze(output);
49
+ } finally {
50
+ seen.delete(value);
51
+ }
52
+ }
53
+
54
+ export function normalizeExtensionBindings(input: unknown): NormalizedExtensionBindings | undefined {
55
+ if (input === undefined) return undefined;
56
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("extensionBindings must be a plain JSON object.");
57
+ const prototype = Object.getPrototypeOf(input);
58
+ if (prototype !== Object.prototype && prototype !== null) throw new Error("extensionBindings must be a plain JSON object.");
59
+ const keys = Object.keys(input);
60
+ if (keys.length > MAX_EXTENSION_BINDING_NAMESPACES) throw new Error(`extensionBindings supports at most ${MAX_EXTENSION_BINDING_NAMESPACES} namespaces.`);
61
+ for (const key of keys) {
62
+ if (!EXTENSION_BINDING_NAMESPACE.test(key)) throw new Error(`extensionBindings namespace '${key}' must use a package-like name followed by '/<positive-version>'.`);
63
+ }
64
+ const value = canonicalizeJson(input, "extensionBindings", 0, new Set(), { value: 0 }) as ExtensionBindings;
65
+ const json = JSON.stringify(value);
66
+ const bytes = Buffer.byteLength(json, "utf8");
67
+ if (bytes > MAX_EXTENSION_BINDINGS_BYTES) throw new Error(`extensionBindings canonical JSON is ${bytes} bytes; maximum is ${MAX_EXTENSION_BINDINGS_BYTES}.`);
68
+ return { value, json };
69
+ }
70
+
71
+ export function encodeExtensionBindings(input: ExtensionBindings | undefined): string | undefined {
72
+ return input === undefined ? undefined : normalizeExtensionBindings(input)!.json;
73
+ }
74
+
75
+ export function omitExtensionBindingsEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
76
+ const { [PI_SUBAGENT_EXTENSION_BINDINGS_ENV]: _extensionBindings, ...sanitized } = env;
77
+ return sanitized;
78
+ }
@@ -0,0 +1,167 @@
1
+ import type {
2
+ ExternalCliReceiptMetadata,
3
+ ExternalCliCapabilityNarrowing,
4
+ ExternalCliRunnerStatus,
5
+ ExternalProcessStatus,
6
+ } from "../../shared/types.ts";
7
+
8
+ const UNSUPPORTED = {
9
+ steer: "The one-shot stdin adapter closes input after launch and cannot accept live steer messages.",
10
+ resume: "The one-shot stdin adapter has no durable external session identity.",
11
+ structuredOutput: "The generic external CLI adapter does not parse a trusted structured result.",
12
+ toolEvents: "The generic external CLI adapter treats stdout as untrusted text, not native Pi tool events.",
13
+ supervisor: "The generic external CLI adapter has no trusted supervisor event transport.",
14
+ forkContext: "Native Pi fork context is not available without an adapter-owned handoff artifact.",
15
+ extensionBindings: "Native Pi extension bindings are never passed to external runners.",
16
+ } as const;
17
+
18
+ const CAPABILITY_KEYS = new Set(Object.keys(UNSUPPORTED));
19
+ const PROMPT_FILE_UNSUPPORTED = {
20
+ ...UNSUPPORTED,
21
+ steer: "The one-shot prompt-file adapter closes input after launch and cannot accept live steer messages.",
22
+ resume: "The one-shot prompt-file adapter does not retain a durable external session identity.",
23
+ } as const;
24
+
25
+ export const CODE_OWNED_EXTERNAL_CLI_ADAPTER_IDS = [
26
+ "codex-exec",
27
+ "codex-exec-writer",
28
+ "claude-code",
29
+ "claude-code-writer",
30
+ "cursor-agent",
31
+ "cursor-agent-writer",
32
+ ] as const;
33
+ export type CodeOwnedExternalCliAdapterId = typeof CODE_OWNED_EXTERNAL_CLI_ADAPTER_IDS[number];
34
+
35
+ const CODE_OWNED_EXTERNAL_CLI_ADAPTER_ID_SET = new Set<string>(CODE_OWNED_EXTERNAL_CLI_ADAPTER_IDS);
36
+ export const CODE_OWNED_EXTERNAL_CLI_ADAPTER_LABEL = CODE_OWNED_EXTERNAL_CLI_ADAPTER_IDS.map((id) => `'${id}'`).join(", ");
37
+
38
+ export function isCodeOwnedExternalCliAdapterId(value: unknown): value is CodeOwnedExternalCliAdapterId {
39
+ return typeof value === "string" && CODE_OWNED_EXTERNAL_CLI_ADAPTER_ID_SET.has(value);
40
+ }
41
+
42
+ const RESERVED_READ_ONLY_ADAPTERS = [
43
+ { name: "claude-code", writer: "claude-code-writer", access: "file-write" },
44
+ { name: "codex-exec", writer: "codex-exec-writer", access: "workspace-write" },
45
+ { name: "cursor-agent", writer: "cursor-agent-writer", access: "workspace-write" },
46
+ ] as const;
47
+
48
+ export function validateCodeOwnedProfileRunner(
49
+ agent: {
50
+ name: string;
51
+ localName?: string;
52
+ aliases?: readonly string[];
53
+ runner?: { type: string; adapter?: string };
54
+ },
55
+ ): string | undefined {
56
+ const selectionNames = [agent.name, ...(agent.localName ? [agent.localName] : []), ...(agent.aliases ?? [])];
57
+ for (const adapter of RESERVED_READ_ONLY_ADAPTERS) {
58
+ if (selectionNames.includes(adapter.name) && !(agent.runner?.type === "external-cli" && agent.runner.adapter === adapter.name)) {
59
+ return `Selection name '${adapter.name}' is reserved for the read-only '${adapter.name}' adapter. Use '${adapter.writer}' for explicit ${adapter.access} access.`;
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ export function parseExternalCliCapabilityNarrowing(value: unknown, label: string): ExternalCliCapabilityNarrowing | undefined {
66
+ if (value === undefined) return undefined;
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
68
+ const input = value as Record<string, unknown>;
69
+ const unknown = Object.keys(input).filter((key) => !CAPABILITY_KEYS.has(key));
70
+ if (unknown.length > 0) throw new Error(`${label} has unsupported fields: ${unknown.join(", ")}.`);
71
+ for (const [key, setting] of Object.entries(input)) {
72
+ if (setting !== false) throw new Error(`${label}.${key} may only be false; user config cannot widen code-owned external adapter capabilities.`);
73
+ }
74
+ return input as ExternalCliCapabilityNarrowing;
75
+ }
76
+
77
+ export function resolveExternalCliRunnerStatus(input: {
78
+ adapter?: CodeOwnedExternalCliAdapterId;
79
+ command: string;
80
+ args?: string[];
81
+ promptDelivery?: "stdin";
82
+ capabilities?: ExternalCliCapabilityNarrowing;
83
+ }): ExternalCliRunnerStatus {
84
+ const codexExec = input.adapter === "codex-exec";
85
+ const codexExecWriter = input.adapter === "codex-exec-writer";
86
+ const claudeCode = input.adapter === "claude-code";
87
+ const claudeCodeWriter = input.adapter === "claude-code-writer";
88
+ const cursorAgent = input.adapter === "cursor-agent";
89
+ const cursorAgentWriter = input.adapter === "cursor-agent-writer";
90
+ const cursor = cursorAgent || cursorAgentWriter;
91
+ const unsupported = cursor ? PROMPT_FILE_UNSUPPORTED : UNSUPPORTED;
92
+ return {
93
+ type: "external-cli",
94
+ command: input.command,
95
+ args: input.args ?? [],
96
+ promptDelivery: cursor ? "prompt-file" : input.promptDelivery ?? "stdin",
97
+ adapter: { id: input.adapter ?? "external-cli", version: 1, executionMode: cursor ? "one-shot-prompt-file" : "one-shot-stdin" },
98
+ ...(codexExec ? { safety: { sandbox: "read-only" as const, approvalPolicy: "never" as const, ephemeral: true as const } } : {}),
99
+ ...(codexExecWriter ? { safety: { access: "workspace-write" as const, sandbox: "workspace-write" as const, approvalPolicy: "never" as const, ephemeral: true as const } } : {}),
100
+ ...(claudeCode ? { safety: { access: "read-only" as const, authentication: "existing-cli-required" as const, permissionMode: "plan" as const, tools: "none" as const, mcp: "empty-strict" as const, settingSources: "user" as const, userSettingsTrust: "required" as const, sessionPersistence: false as const } } : {}),
101
+ ...(claudeCodeWriter ? { safety: { access: "workspace-write" as const, authentication: "existing-cli-required" as const, permissionMode: "acceptEdits" as const, tools: "Read,Write,Edit,Glob,Grep" as const, mcp: "empty-strict" as const, settingSources: "user" as const, userSettingsTrust: "required" as const, sessionPersistence: false as const } } : {}),
102
+ ...(cursorAgent ? { safety: { access: "read-only" as const, authentication: "cursor-api-key-or-existing-login" as const, mode: "ask" as const, sandbox: "enabled" as const, workspaceTrust: "existing-required" as const, sessionReuse: false as const } } : {}),
103
+ ...(cursorAgentWriter ? { safety: { access: "workspace-write" as const, authentication: "cursor-api-key-or-existing-login" as const, mode: "print" as const, sandbox: "enabled" as const, workspaceTrust: "existing-required" as const, sessionReuse: false as const } } : {}),
104
+ capabilities: {
105
+ stop: true,
106
+ steer: false,
107
+ resume: false,
108
+ structuredOutput: false,
109
+ toolEvents: false,
110
+ supervisor: "unsupported",
111
+ forkContext: false,
112
+ extensionBindings: false,
113
+ },
114
+ unsupportedReasons: unsupported,
115
+ nonResumableReason: unsupported.resume,
116
+ };
117
+ }
118
+
119
+ export function normalizeExternalCliRunnerStatus(value: unknown): ExternalCliRunnerStatus | undefined {
120
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
121
+ const input = value as Record<string, unknown>;
122
+ if (input.type !== "external-cli" || typeof input.command !== "string" || !input.command.trim()) return undefined;
123
+ const args = Array.isArray(input.args) && input.args.every((arg) => typeof arg === "string")
124
+ ? input.args
125
+ : undefined;
126
+ const promptDelivery = input.promptDelivery === "stdin" ? "stdin" : undefined;
127
+ const adapterId = input.adapter && typeof input.adapter === "object" && !Array.isArray(input.adapter)
128
+ ? (input.adapter as Record<string, unknown>).id
129
+ : undefined;
130
+ if (adapterId === "grok-build") {
131
+ return {
132
+ type: "external-cli",
133
+ command: input.command,
134
+ args: args ?? [],
135
+ promptDelivery: "prompt-file",
136
+ adapter: { id: "grok-build", version: 1, executionMode: "one-shot-prompt-file" },
137
+ capabilities: { stop: true, steer: false, resume: false, structuredOutput: false, toolEvents: false, supervisor: "unsupported", forkContext: false, extensionBindings: false },
138
+ unsupportedReasons: PROMPT_FILE_UNSUPPORTED,
139
+ nonResumableReason: PROMPT_FILE_UNSUPPORTED.resume,
140
+ };
141
+ }
142
+ const adapter = isCodeOwnedExternalCliAdapterId(adapterId) ? adapterId : undefined;
143
+ return resolveExternalCliRunnerStatus({ ...(adapter ? { adapter } : {}), command: input.command, ...(args ? { args } : {}), ...(promptDelivery ? { promptDelivery } : {}) });
144
+ }
145
+
146
+ export function externalCliReceiptMetadata(input: {
147
+ runner: ExternalCliRunnerStatus;
148
+ externalProcess?: ExternalProcessStatus;
149
+ outputReference?: string;
150
+ }): ExternalCliReceiptMetadata {
151
+ const { runner } = input;
152
+ return {
153
+ adapter: { ...runner.adapter },
154
+ capabilities: { ...runner.capabilities },
155
+ ...(runner.safety ? { safety: { ...runner.safety } } : {}),
156
+ ...(input.externalProcess ? {
157
+ outputArtifacts: {
158
+ stdoutPath: input.externalProcess.stdoutPath,
159
+ stderrPath: input.externalProcess.stderrPath,
160
+ ...(input.outputReference || input.externalProcess.finalOutputPath ? { finalOutputPath: input.outputReference ?? input.externalProcess.finalOutputPath } : {}),
161
+ },
162
+ } : input.outputReference ? { outputArtifacts: { finalOutputPath: input.outputReference } } : {}),
163
+ handoff: { mode: "fresh" },
164
+ supervisor: { mode: "unsupported", reason: runner.unsupportedReasons.supervisor },
165
+ nonResumableReason: runner.nonResumableReason,
166
+ };
167
+ }
@@ -0,0 +1,122 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+
5
+ const MAX_PROBE_OUTPUT_BYTES = 256 * 1024;
6
+ const MAX_PROBE_TIMEOUT_MS = 5_000;
7
+ const MAX_CACHE_ENTRIES = 64;
8
+
9
+ export type ExternalCliPreflightInvalidationReason = "launch" | "auth" | "parser" | "permission";
10
+
11
+ export interface ExternalCliPreflightSpec {
12
+ id: string;
13
+ versionArgs: readonly string[];
14
+ helpArgs: readonly string[];
15
+ evidenceArgs?: readonly string[];
16
+ evidenceLabel?: string;
17
+ probeTimeoutMs?: number;
18
+ validate?: (result: ExternalCliPreflightResult) => void;
19
+ }
20
+
21
+ export interface ExternalCliPreflightResult {
22
+ binaryPath: string;
23
+ binaryMtimeMs: number;
24
+ version: string;
25
+ help: string;
26
+ evidence?: string;
27
+ cacheHit: boolean;
28
+ }
29
+
30
+ type CachedPreflight = Omit<ExternalCliPreflightResult, "cacheHit">;
31
+
32
+ const cache = new Map<string, CachedPreflight>();
33
+ const lookup = new Map<string, string>();
34
+
35
+ function resolveBinary(command: string, env: NodeJS.ProcessEnv): string {
36
+ if (path.isAbsolute(command) || command.includes(path.sep)) {
37
+ const resolved = path.resolve(command);
38
+ fs.accessSync(resolved, fs.constants.X_OK);
39
+ return resolved;
40
+ }
41
+ const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
42
+ for (const directory of (env.PATH ?? "").split(path.delimiter)) {
43
+ if (!directory) continue;
44
+ for (const extension of extensions) {
45
+ const candidate = path.join(directory, `${command}${extension}`);
46
+ try {
47
+ fs.accessSync(candidate, fs.constants.X_OK);
48
+ return fs.realpathSync(candidate);
49
+ } catch {}
50
+ }
51
+ }
52
+ throw new Error(`External CLI binary '${command}' was not found on PATH.`);
53
+ }
54
+
55
+ function probeWithTimeout(binaryPath: string, args: readonly string[], env: NodeJS.ProcessEnv, label: string, timeoutMs: number, cwd?: string): string {
56
+ const result = spawnSync(binaryPath, [...args], {
57
+ cwd,
58
+ env,
59
+ encoding: "utf-8",
60
+ killSignal: "SIGKILL",
61
+ maxBuffer: MAX_PROBE_OUTPUT_BYTES,
62
+ timeout: timeoutMs,
63
+ windowsHide: true,
64
+ });
65
+ if (result.error) throw new Error(`External CLI ${label} preflight failed: ${result.error.message}`, { cause: result.error });
66
+ if (result.status !== 0) throw new Error(`External CLI ${label} preflight exited with code ${result.status}: ${(result.stderr || result.stdout).trim()}`);
67
+ return result.stdout.trim();
68
+ }
69
+
70
+ function narrowPositiveInteger(value: number | undefined, ceiling: number, label: string): number {
71
+ if (value === undefined) return ceiling;
72
+ if (!Number.isSafeInteger(value) || value <= 0 || value > ceiling) throw new Error(`${label} may only narrow the code-owned ceiling of ${ceiling}.`);
73
+ return value;
74
+ }
75
+
76
+ function specKey(spec: ExternalCliPreflightSpec): string {
77
+ return JSON.stringify([spec.id, spec.versionArgs, spec.helpArgs, spec.evidenceArgs, spec.evidenceLabel, spec.probeTimeoutMs]);
78
+ }
79
+
80
+ export function preflightExternalCli(command: string, spec: ExternalCliPreflightSpec, env: NodeJS.ProcessEnv, cwd?: string): ExternalCliPreflightResult {
81
+ const binaryPath = resolveBinary(command, env);
82
+ const binaryMtimeMs = fs.statSync(binaryPath).mtimeMs;
83
+ const lookupKey = JSON.stringify([binaryPath, binaryMtimeMs, specKey(spec)]);
84
+ const cachedKey = lookup.get(lookupKey);
85
+ const cached = cachedKey ? cache.get(cachedKey) : undefined;
86
+ const probeTimeoutMs = narrowPositiveInteger(spec.probeTimeoutMs, MAX_PROBE_TIMEOUT_MS, "probeTimeoutMs");
87
+ const base = cached ?? {
88
+ binaryPath,
89
+ binaryMtimeMs,
90
+ version: probeWithTimeout(binaryPath, spec.versionArgs, env, "version", probeTimeoutMs, cwd),
91
+ help: probeWithTimeout(binaryPath, spec.helpArgs, env, "help", probeTimeoutMs, cwd),
92
+ };
93
+ const evidence = spec.evidenceArgs
94
+ ? probeWithTimeout(binaryPath, spec.evidenceArgs, env, spec.evidenceLabel ?? "evidence", probeTimeoutMs, cwd)
95
+ : undefined;
96
+ const result = { ...base, ...(evidence !== undefined ? { evidence } : {}), cacheHit: Boolean(cached) };
97
+ spec.validate?.(result);
98
+ if (!cached) {
99
+ const cacheKey = JSON.stringify([binaryPath, base.version, binaryMtimeMs, specKey(spec)]);
100
+ cache.set(cacheKey, base);
101
+ lookup.set(lookupKey, cacheKey);
102
+ while (cache.size > MAX_CACHE_ENTRIES) {
103
+ const oldest = cache.keys().next().value as string;
104
+ cache.delete(oldest);
105
+ for (const [candidateLookup, candidateCache] of lookup) if (candidateCache === oldest) lookup.delete(candidateLookup);
106
+ }
107
+ }
108
+ return result;
109
+ }
110
+
111
+ export function invalidateExternalCliPreflight(command: string, spec: ExternalCliPreflightSpec, _reason: ExternalCliPreflightInvalidationReason): void {
112
+ const key = specKey(spec);
113
+ for (const [cacheKey, entry] of cache) {
114
+ if (entry.binaryPath === command || entry.binaryPath.endsWith(`${path.sep}${command}`) || cacheKey.includes(key)) cache.delete(cacheKey);
115
+ }
116
+ for (const [lookupKey, cacheKey] of lookup) if (!cache.has(cacheKey)) lookup.delete(lookupKey);
117
+ }
118
+
119
+ export function clearExternalCliPreflightCacheForTests(): void {
120
+ cache.clear();
121
+ lookup.clear();
122
+ }