faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,508 @@
1
+ import { spawn } from "node:child_process";
2
+ import { claudeHarness } from "./claude/index.mjs";
3
+ import { codexHarness } from "./codex/index.mjs";
4
+ import { agyHarness } from "./agy/index.mjs";
5
+ import { dshHarness } from "./dsh/index.mjs";
6
+ import { zcodeHarness } from "./zcode/index.mjs";
7
+ import { execJsonlHarness } from "./exec-jsonl/index.mjs";
8
+ import { replayHarness } from "./replay/index.mjs";
9
+
10
+ /** Current wire-contract version for runner protocol artifacts. */
11
+ export const PROTOCOL_SCHEMA_VERSION = 3;
12
+
13
+ /** Version of the runner protocol implementation. */
14
+ export const CONTRACT_VERSION = "0.3.0";
15
+
16
+ const HARNESSES = new Map([
17
+ ["claude", claudeHarness],
18
+ ["codex", codexHarness],
19
+ ["agy", agyHarness],
20
+ ["dsh", dshHarness],
21
+ ["zcode", zcodeHarness],
22
+ ["exec-jsonl", execJsonlHarness],
23
+ ["replay", replayHarness],
24
+ ]);
25
+
26
+ const CAPABILITY_NAMES = new Set([
27
+ "structuredOutput",
28
+ "promptTransport",
29
+ "sandbox",
30
+ "permissions",
31
+ "continuation",
32
+ "tokenBudget",
33
+ "costBudget",
34
+ "usage",
35
+ "cost",
36
+ "toolPolicy",
37
+ "streamsOutput",
38
+ ]);
39
+
40
+ /** @typedef {"structuredOutput"|"promptTransport"|"sandbox"|"permissions"|"continuation"|"tokenBudget"|"costBudget"|"usage"|"cost"|"toolPolicy"|"streamsOutput"} CapabilityName */
41
+
42
+ /** @typedef {{structuredOutput: boolean, promptTransport: "stdin"|"argv", sandbox: boolean, permissions: boolean, continuation: boolean, tokenBudget: boolean, costBudget: boolean, usage: boolean, cost: boolean, toolPolicy: boolean, streamsOutput: boolean, maxArgvPromptBytes?: number}} HarnessCapabilities */
43
+
44
+ /** @typedef {{structuredOutput?: boolean, promptTransport?: "stdin"|"argv", sandbox?: boolean, permissions?: boolean, continuation?: boolean, tokenBudget?: boolean, costBudget?: boolean, usage?: boolean, cost?: boolean, toolPolicy?: boolean, streamsOutput?: boolean}} CapabilityRequirements */
45
+
46
+ /** @typedef {{executable: string, args: string[], promptTransport: "stdin"|"argv", input: string|null, env?: Record<string, string|null>}} HarnessCommand */
47
+
48
+ /** @typedef {HarnessCommand & {harness: string, model: string, capabilities: HarnessCapabilities}} ProviderCommand */
49
+
50
+ /**
51
+ * Which runtime field controls command execution, which values execute, and
52
+ * the value used when the contract omits that field. `null` means the harness
53
+ * has no permission mode that can deny command execution.
54
+ *
55
+ * @typedef {{field: "permissionMode"|"sandbox", executingModes: string[], defaultMode: string}|null} PermissionExecutionPolicy
56
+ */
57
+
58
+ /** @typedef {{status: "done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled", result: string|null, continuationId: string|null, usage: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}, costUsd: number|null, error: {code: string, message: string, resetAt?: string|null}|null, exhaustedUntil?: string|null, judgeCandidates?: number}} ProviderEnvelope */
59
+
60
+ /**
61
+ * One declared runtime. `harness` names a registered adapter (`claude`,
62
+ * `codex`, `agy`, `dsh`, `zcode`, `exec-jsonl`, or `replay`) and `model` names
63
+ * what that harness asks; the two are independent. replay requires
64
+ * `config["replay.recording"]` for commands, and dsh requires
65
+ * `config.provider` for the provider route every attempt runs on.
66
+ *
67
+ * @typedef {{id?: string, harness: string, model: string, reasoning?: string, sandbox?: string, permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, tier?: number|string, vendor?: string}} HarnessRuntime
68
+ */
69
+
70
+ /**
71
+ * Mechanical worker tool policy sent to the provider boundary:
72
+ * `foregroundOnly` rejects background tool invocations,
73
+ * `maxToolOutputBytes` bounds each tool result head-plus-tail, `workspace`
74
+ * names the attempt's working directory, `writeFiles`/`writeRoots` carry the
75
+ * node's declared write scope, and `maxReadLines` bounds a whole-file read.
76
+ * Claude-compatible adapters enforce it through hook settings; an adapter
77
+ * that cannot prove enforcement must never receive it.
78
+ *
79
+ * @typedef {{foregroundOnly: boolean, maxToolOutputBytes: number|null, workspace: string, writeFiles: string[], writeRoots: string[], maxReadLines: number|null}} ToolPolicy
80
+ */
81
+
82
+ /** Line count above which a whole-file read is denied by the tool policy hook. */
83
+ export const READ_LINE_LIMIT = 1500;
84
+
85
+ /** @typedef {{schema?: object, schemaPath?: string, continuationId?: string|null, toolPolicy?: ToolPolicy, env?: Record<string, string>}} CommandOptions */
86
+
87
+ /** @typedef {{preferStructured?: boolean, exitCode?: number|null, signal?: string|null, stderr?: string}} NormalizeOptions */
88
+
89
+ /**
90
+ * One provider adapter: capabilities plus executable, version, command, and
91
+ * result-normalization behavior.
92
+ *
93
+ * @typedef {{capabilities: HarnessCapabilities, permissionExecution: PermissionExecutionPolicy, executable: (runtime: HarnessRuntime) => string, versionArgs: (runtime: HarnessRuntime) => string[], parseVersion: (stdout: string, stderr?: string) => string|null, command: (runtime: HarnessRuntime, prompt: string, options: CommandOptions) => HarnessCommand, normalize: (stdout: string, exitCode: number|null, signal: string|null, options?: NormalizeOptions) => ProviderEnvelope}} HarnessAdapter
94
+ */
95
+
96
+ /**
97
+ * Result of a read-only runtime probe.
98
+ *
99
+ * @typedef {{id: string|null, harness: string, executable: string, model: string, version: string|null, capabilities: HarnessCapabilities, requiredCapabilities: CapabilityRequirements, requiredCapabilitySets: CapabilityRequirements[], ok: boolean, detail: string|null, availability?: {available: boolean, exhaustedUntil: string|null, reason: string}, live?: boolean, liveStatus?: string, usage?: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}, costUsd?: number|null}} ProbeResult
100
+ */
101
+
102
+ /** @typedef {{id?: string}} RuntimeIdentity */
103
+
104
+ /**
105
+ * Every registered harness name, for callers that must account for the whole
106
+ * registry. Declaration order only — callers that display harnesses sort it
107
+ * themselves.
108
+ *
109
+ * @returns {string[]}
110
+ */
111
+ export function registeredHarnesses() {
112
+ return [...HARNESSES.keys()];
113
+ }
114
+
115
+ /**
116
+ * @param {string} name
117
+ * @returns {HarnessAdapter}
118
+ */
119
+ export function getHarness(name) {
120
+ const harness = HARNESSES.get(name);
121
+ if (!harness) throw new TypeError(`unknown harness: ${name}`);
122
+ return harness;
123
+ }
124
+
125
+ /**
126
+ * @param {{harness: string}} runtime
127
+ * @returns {HarnessCapabilities}
128
+ */
129
+ export function harnessCapabilities(runtime) {
130
+ return { ...getHarness(runtime.harness).capabilities };
131
+ }
132
+
133
+ /**
134
+ * @param {{harness: string, permissionMode?: string, sandbox?: string}} runtime
135
+ * @returns {{executes: boolean, field: "permissionMode"|"sandbox"|null, mode: string|null, executingModes: string[]}}
136
+ */
137
+ export function resolvePermissionExecution(runtime) {
138
+ const policy = getHarness(runtime.harness).permissionExecution;
139
+ if (!policy) return { executes: true, field: null, mode: null, executingModes: [] };
140
+ const mode = /** @type {string} */ (runtime[policy.field] ?? policy.defaultMode);
141
+ return { executes: policy.executingModes.includes(mode), field: policy.field, mode, executingModes: policy.executingModes };
142
+ }
143
+
144
+ /**
145
+ * The vendor a harness talks to when no provider configuration says
146
+ * otherwise. `replay` and `exec-jsonl` stand in for whatever the recording or
147
+ * the exec'd binary actually is, so neither gets a default here — a contract
148
+ * using either must declare `vendor` outright.
149
+ */
150
+ const DEFAULT_HARNESS_VENDORS = Object.freeze({
151
+ claude: "anthropic",
152
+ codex: "openai",
153
+ agy: "google",
154
+ zcode: "zhipu",
155
+ });
156
+
157
+ /**
158
+ * Resolve one runtime's vendor identity: an explicit `vendor` wins outright,
159
+ * then a provider-configuration override (the codex `model_provider` trap —
160
+ * a codex runtime configured for deepseek is a deepseek vendor, not openai),
161
+ * then the harness's own default. `null` means the caller must reject the
162
+ * runtime: nothing here named a vendor for it.
163
+ *
164
+ * @param {{harness: string, vendor?: string, config?: Record<string, unknown>}} runtime
165
+ * @returns {string|null}
166
+ */
167
+ export function resolveVendor(runtime) {
168
+ if (typeof runtime.vendor === "string" && runtime.vendor.length) return runtime.vendor;
169
+ const provider = runtime.config?.model_provider;
170
+ if (typeof provider === "string" && provider.length) return provider;
171
+ return /** @type {Record<string, string>} */ (DEFAULT_HARNESS_VENDORS)[runtime.harness] ?? null;
172
+ }
173
+
174
+ /**
175
+ * Build one provider invocation. Prompt transport is explicit in the result:
176
+ * stdin adapters return `input`, while argv adapters append the prompt. An
177
+ * optional `env` overlay is merged over the runner environment at spawn time;
178
+ * a null value removes the ambient variable. A caller-supplied `options.env`
179
+ * merges over the adapter's own overlay here, once, for every harness.
180
+ *
181
+ * @param {HarnessRuntime} runtime
182
+ * @param {string} prompt
183
+ * @param {CommandOptions} options
184
+ * @returns {ProviderCommand}
185
+ */
186
+ export function providerCommand(runtime, prompt, options = {}) {
187
+ const harness = getHarness(runtime.harness);
188
+ const command = harness.command(runtime, prompt, options);
189
+ if (options.env) command.env = { ...command.env, ...options.env };
190
+ if (command.promptTransport === "argv") {
191
+ const limit = runtime.maxArgvPromptBytes ?? harness.capabilities.maxArgvPromptBytes;
192
+ if (typeof limit === "number" && Number.isFinite(limit) && Buffer.byteLength(prompt, "utf8") > limit) {
193
+ const error = /** @type {Error & {code: string}} */ (new Error(`prompt exceeds argv limit of ${limit} bytes for ${runtime.harness}`));
194
+ error.code = "prompt_too_large";
195
+ throw error;
196
+ }
197
+ }
198
+ return {
199
+ ...command,
200
+ harness: runtime.harness,
201
+ model: runtime.model,
202
+ capabilities: harnessCapabilities(runtime),
203
+ };
204
+ }
205
+
206
+ /**
207
+ * @param {string|{harness: string}} runtimeOrHarness
208
+ * @param {string} stdout
209
+ * @param {number|null} exitCode
210
+ * @param {string|null} signal
211
+ * @param {NormalizeOptions} options
212
+ * @returns {ProviderEnvelope}
213
+ */
214
+ export function normalizeProviderResult(runtimeOrHarness, stdout, exitCode, signal, options = {}) {
215
+ const runtime = typeof runtimeOrHarness === "string" ? { harness: runtimeOrHarness } : runtimeOrHarness;
216
+ const harness = getHarness(runtime.harness);
217
+ return harness.normalize(stdout, exitCode, signal, options);
218
+ }
219
+
220
+ /**
221
+ * Normalize a provider envelope or recorded response into the availability
222
+ * shape used by doctor and runtime assignment.
223
+ *
224
+ * @param {string|{harness: string}} runtimeOrHarness
225
+ * @param {unknown} response
226
+ * @param {number|null} [exitCode]
227
+ * @param {string|null} [signal]
228
+ * @returns {{available: boolean, exhaustedUntil: string|null, reason: string}}
229
+ */
230
+ export function normalizeProviderAvailability(runtimeOrHarness, response, exitCode = 0, signal = null) {
231
+ let envelope;
232
+ try {
233
+ envelope = response && typeof response === "object" && !Array.isArray(response) && typeof /** @type {Record<string, unknown>} */ (response).status === "string"
234
+ ? /** @type {ProviderEnvelope} */ (response)
235
+ : normalizeProviderResult(runtimeOrHarness, String(response ?? ""), exitCode, signal);
236
+ } catch (error) {
237
+ return { available: false, exhaustedUntil: null, reason: error instanceof Error ? error.message : "provider_unavailable" };
238
+ }
239
+ const error = envelope.error;
240
+ const code = typeof error?.code === "string" ? error.code : "";
241
+ const message = typeof error?.message === "string" ? error.message : "";
242
+ const text = `${code} ${message}`;
243
+ if (envelope.status === "done" || envelope.status === "no-op") return { available: true, exhaustedUntil: null, reason: "ready" };
244
+ const classified = classifyAvailabilityText(text);
245
+ // A hard balance stop (DeepSeek's 402 "Insufficient Balance") has no reset
246
+ // instant to report, unlike quota_exhausted, so it must be classified before
247
+ // that branch even though its text never matches the quota pattern.
248
+ if (classified?.reason === "insufficient_balance") return classified;
249
+ if (envelope.status === "exhausted" || classified?.reason === "quota_exhausted") {
250
+ return { available: false, exhaustedUntil: exhaustedUntilOf(envelope), reason: code || "quota_exhausted" };
251
+ }
252
+ if (classified?.reason === "authentication_failed") return classified;
253
+ return { available: false, exhaustedUntil: null, reason: code || "provider_unavailable" };
254
+ }
255
+
256
+ /**
257
+ * The absolute instant an exhaustion envelope announces, taken from whichever
258
+ * field carries it: the envelope's own `exhaustedUntil`, the error's `resetAt`,
259
+ * or a reset sentence inside the error message. `null` means the provider named
260
+ * no reset, which is the controller's signal to take the failover edge instead
261
+ * of waiting on the same runtime.
262
+ *
263
+ * @param {unknown} envelope
264
+ * @returns {string|null}
265
+ */
266
+ export function exhaustedUntilOf(envelope) {
267
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return null;
268
+ const record = /** @type {Record<string, unknown>} */ (envelope);
269
+ const error = record.error && typeof record.error === "object"
270
+ ? /** @type {Record<string, unknown>} */ (record.error)
271
+ : null;
272
+ return resetTimestamp(record.exhaustedUntil ?? error?.resetAt ?? (typeof error?.message === "string" ? error.message : null));
273
+ }
274
+
275
+ /**
276
+ * Classify raw provider-produced text (a structured envelope's `error.code
277
+ * error.message`, or a probe's raw stderr on a non-zero exit) into the same
278
+ * insufficient-balance/quota/authentication reasons `normalizeProviderAvailability`
279
+ * recognizes. Shared so a CLI-missing exit and a raw stderr balance/quota
280
+ * message are classified by one set of patterns, never two drifting copies.
281
+ *
282
+ * @param {string} text
283
+ * @returns {{available: false, exhaustedUntil: string|null, reason: string}|null} null when text names none of the known patterns
284
+ */
285
+ function classifyAvailabilityText(text) {
286
+ if (/insufficient balance/iu.test(text) || /\b402\b/u.test(text)) {
287
+ return { available: false, exhaustedUntil: null, reason: "insufficient_balance" };
288
+ }
289
+ if (/quota|rate.?limit|usage limit|limit exhausted|1310/iu.test(text)) {
290
+ return { available: false, exhaustedUntil: resetTimestamp(text), reason: "quota_exhausted" };
291
+ }
292
+ if (/auth|credential|unauthori[sz]ed|forbidden|invalid.*(?:key|token)|(?:api|access) key|login/iu.test(text)) {
293
+ return { available: false, exhaustedUntil: null, reason: "authentication_failed" };
294
+ }
295
+ return null;
296
+ }
297
+
298
+ /** @param {unknown} value @returns {string|null} */
299
+ function resetTimestamp(value) {
300
+ if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
301
+ if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
302
+ if (typeof value !== "string") return null;
303
+ const match = /reset(?:s| at| on)?\s+(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:?\d{2})?)/iu.exec(value);
304
+ const input = match?.[1] ?? value;
305
+ const normalized = input.includes("T") || /(?:Z|[+-]\d{2}:?\d{2})$/u.test(input) ? input : `${input.replace(" ", "T")}Z`;
306
+ const parsed = Date.parse(normalized);
307
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
308
+ }
309
+
310
+ /**
311
+ * Validate a partial capability requirement against an adapter declaration.
312
+ * The runtime JSON remains the authoritative source for requirement shape.
313
+ *
314
+ * @param {CapabilityRequirements|undefined} requirements
315
+ * @param {string} label
316
+ * @returns {CapabilityRequirements}
317
+ */
318
+ export function validateCapabilityRequirements(requirements, label = "requiredCapabilities") {
319
+ if (requirements === undefined) return {};
320
+ if (!requirements || typeof requirements !== "object" || Array.isArray(requirements)) {
321
+ throw new TypeError(`${label} must be an object`);
322
+ }
323
+ for (const [name, value] of Object.entries(requirements)) {
324
+ if (!isCapabilityName(name)) throw new TypeError(`${label}.${name} is unknown`);
325
+ if (name === "promptTransport") {
326
+ if (value !== "stdin" && value !== "argv") throw new TypeError(`${label}.promptTransport is invalid`);
327
+ } else if (typeof value !== "boolean") {
328
+ throw new TypeError(`${label}.${name} must be boolean`);
329
+ }
330
+ }
331
+ return { ...requirements };
332
+ }
333
+
334
+ /**
335
+ * @param {HarnessCapabilities} capabilities
336
+ * @param {CapabilityRequirements} requirements
337
+ * @returns {string[]}
338
+ */
339
+ export function missingCapabilities(capabilities, requirements = {}) {
340
+ return Object.keys(requirements).flatMap((name) => {
341
+ if (!isCapabilityName(name)) return [];
342
+ const required = requirements[name];
343
+ return capabilities[name] === required
344
+ ? []
345
+ : [`${name}=${String(required)} (harness provides ${name}=${String(capabilities[name])})`];
346
+ });
347
+ }
348
+
349
+ /** @param {string} name @returns {name is CapabilityName} */
350
+ function isCapabilityName(name) {
351
+ return CAPABILITY_NAMES.has(name);
352
+ }
353
+
354
+ /** @param {HarnessCapabilities} capabilities @param {CapabilityRequirements[]} requirementSets */
355
+ function missingCapabilitySets(capabilities, requirementSets) {
356
+ return requirementSets.flatMap((requirements, index) =>
357
+ missingCapabilities(capabilities, requirements).map((missing) => `requirement ${index + 1}: ${missing}`),
358
+ );
359
+ }
360
+
361
+ /**
362
+ * Probe an executable version without sending a prompt or exposing secrets.
363
+ *
364
+ * @param {HarnessRuntime} runtime
365
+ * @param {{cwd?: string, timeoutSec?: number, requiredCapabilities?: CapabilityRequirements, requiredCapabilitySets?: CapabilityRequirements[]}} options
366
+ * @returns {Promise<ProbeResult>}
367
+ */
368
+ export function probeRuntime(runtime, options = {}) {
369
+ const harness = getHarness(runtime.harness);
370
+ const executable = harness.executable(runtime);
371
+ const requirementSets = (options.requiredCapabilitySets ?? [options.requiredCapabilities])
372
+ .filter((requirements) => requirements !== undefined)
373
+ .map((requirements, index) => validateCapabilityRequirements(requirements, `requiredCapabilitySets[${index}]`));
374
+ const missingEnvironment = missingEnvironmentVariables(runtime);
375
+ const base = {
376
+ id: runtime.id ?? null,
377
+ harness: runtime.harness,
378
+ executable,
379
+ model: runtime.model,
380
+ version: null,
381
+ capabilities: harnessCapabilities(runtime),
382
+ requiredCapabilities: requirementSets.length === 1 ? requirementSets[0] : {},
383
+ requiredCapabilitySets: requirementSets,
384
+ ok: false,
385
+ detail: null,
386
+ availability: { available: false, exhaustedUntil: null, reason: "provider_unavailable" },
387
+ };
388
+ const missing = missingCapabilitySets(base.capabilities, requirementSets);
389
+ const args = harness.versionArgs(runtime);
390
+ const timeoutSec = options.timeoutSec ?? 120;
391
+ const identity = (/** @type {string|null} */ version) => `${runtime.harness} · ${executable} · ${runtime.model} · ${version ?? "version unavailable"}`;
392
+ const missingEnvironmentDetail = missingEnvironment.length
393
+ ? `missing environment variable ${missingEnvironment.join(", ")}`
394
+ : null;
395
+ return new Promise((settle) => {
396
+ let child;
397
+ try {
398
+ child = spawn(executable, args, {
399
+ cwd: options.cwd,
400
+ env: process.env,
401
+ stdio: ["ignore", "pipe", "pipe"],
402
+ });
403
+ } catch (error) {
404
+ settle({
405
+ ...base,
406
+ availability: { available: false, exhaustedUntil: null, reason: error && typeof error === "object" && "code" in error && error.code === "ENOENT"
407
+ || /(?:ENOENT|not found|no such file)/iu.test(error instanceof Error ? error.message : String(error)) ? "not_found" : "provider_unavailable" },
408
+ detail: `${identity(null)} · ${[missingEnvironmentDetail, redactSecrets(error instanceof Error ? error.message : String(error))].filter(Boolean).join(" · ")}`,
409
+ });
410
+ return;
411
+ }
412
+ let stdout = "";
413
+ let stderr = "";
414
+ let settled = false;
415
+ /** @type {ReturnType<typeof setTimeout>|null} */
416
+ let timer = null;
417
+ /** @param {ProbeResult} result */
418
+ const finish = (result) => {
419
+ if (settled) return;
420
+ settled = true;
421
+ if (timer) clearTimeout(timer);
422
+ settle(result);
423
+ };
424
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
425
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
426
+ timer = setTimeout(() => {
427
+ child.kill("SIGTERM");
428
+ finish({
429
+ ...base,
430
+ availability: { available: false, exhaustedUntil: null, reason: "provider_unavailable" },
431
+ detail: `${identity(null)} · ${[missingEnvironmentDetail, `no response within ${timeoutSec}s`].filter(Boolean).join(" · ")}`,
432
+ });
433
+ }, timeoutSec * 1_000);
434
+ child.once("error", (error) => {
435
+ const message = error instanceof Error ? error.message : String(error);
436
+ finish({
437
+ ...base,
438
+ availability: { available: false, exhaustedUntil: null, reason: /(?:ENOENT|not found|no such file)/iu.test(message) ? "not_found" : "provider_unavailable" },
439
+ detail: `${identity(null)} · ${[missingEnvironmentDetail, redactSecrets(message)].filter(Boolean).join(" · ")}`,
440
+ });
441
+ });
442
+ child.once("close", (exitCode, signal) => {
443
+ const version = harness.parseVersion(redactSecrets(stdout), redactSecrets(stderr));
444
+ const withVersion = { ...base, version };
445
+ if (signal || exitCode !== 0) {
446
+ finish({
447
+ ...withVersion,
448
+ availability: classifyAvailabilityText(stderr) ?? { available: false, exhaustedUntil: null, reason: "provider_unavailable" },
449
+ detail: `${identity(version)} · ${[missingEnvironmentDetail, lastLine(stderr) ?? `${executable} exited with code ${exitCode}`].filter(Boolean).join(" · ")}`,
450
+ });
451
+ return;
452
+ }
453
+ if (!version) {
454
+ finish({
455
+ ...withVersion,
456
+ availability: { available: false, exhaustedUntil: null, reason: "provider_unavailable" },
457
+ detail: `${identity(null)} · ${[missingEnvironmentDetail, "unable to determine version"].filter(Boolean).join(" · ")}`,
458
+ });
459
+ return;
460
+ }
461
+ const problems = [];
462
+ if (missingEnvironmentDetail) problems.push(missingEnvironmentDetail);
463
+ if (missing.length) problems.push(`missing capabilities: ${missing.join(", ")}`);
464
+ finish({
465
+ ...withVersion,
466
+ ok: problems.length === 0,
467
+ availability: problems.length === 0
468
+ ? { available: true, exhaustedUntil: null, reason: "ready" }
469
+ : { available: false, exhaustedUntil: null, reason: missingEnvironmentDetail ? "authentication_required" : "provider_unavailable" },
470
+ detail: `${identity(version)}${problems.length ? ` · ${problems.join(" · ")}` : ""}`,
471
+ });
472
+ });
473
+ });
474
+ }
475
+
476
+ /**
477
+ * @param {HarnessRuntime} runtime
478
+ * @returns {string[]}
479
+ */
480
+ function missingEnvironmentVariables(runtime) {
481
+ /** @type {string[]} */
482
+ const names = [];
483
+ for (const [key, value] of Object.entries(runtime.config ?? {})) {
484
+ if (!key.endsWith(".env_key")) continue;
485
+ if (typeof value === "string" && value.length > 0 && !process.env[value]) names.push(value);
486
+ }
487
+ return [...new Set(names)];
488
+ }
489
+
490
+ /**
491
+ * @param {string} text
492
+ * @returns {string|null}
493
+ */
494
+ function lastLine(text) {
495
+ return redactSecrets(text.trim().split(/\r?\n/u).at(-1) || "") || null;
496
+ }
497
+
498
+ /**
499
+ * @param {string} text
500
+ * @returns {string}
501
+ */
502
+ function redactSecrets(text) {
503
+ let result = text;
504
+ for (const value of Object.values(process.env)) {
505
+ if (typeof value === "string" && value.length >= 4) result = result.split(value).join("[REDACTED]");
506
+ }
507
+ return result;
508
+ }