omp-conductor 0.18.2 → 0.19.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 (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/config.ts CHANGED
@@ -282,6 +282,10 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
282
282
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
283
283
  maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo,
284
284
  dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
285
+ // Same `!== undefined` reading as `dailySpendUsd`: an explicit `null` is a
286
+ // deliberate "derive the per-run allowance from the daily cap" (#851), not
287
+ // an omission that should inherit a global override.
288
+ maxRunSpendUsd: o.maxRunSpendUsd !== undefined ? o.maxRunSpendUsd : defaults.maxRunSpendUsd,
285
289
  planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
286
290
  workerMaxTurns,
287
291
  workerMaxTurnsCeiling:
@@ -1058,6 +1062,12 @@ function gatesCmdPath(rel: readonly PropertyKey[]): boolean {
1058
1062
  return rel.some((s, i) => s === "gates" && typeof rel[i + 1] === "number" && rel[rel.length - 1] === "cmd");
1059
1063
  }
1060
1064
  function capProblem(key: string, found: string): string {
1065
+ if (key === "maxRunSpendUsd") {
1066
+ return (
1067
+ "caps.maxRunSpendUsd must be a positive finite number or null " +
1068
+ `(derive it from dailySpendUsd), found ${found}`
1069
+ );
1070
+ }
1061
1071
  return key === "dailySpendUsd"
1062
1072
  ? `caps.dailySpendUsd must be a non-negative finite number or null (no cap), found ${found}`
1063
1073
  : `caps.${key} must be a non-negative finite number, found ${found}`;
@@ -1105,6 +1115,27 @@ function finalize(data: unknown, path: string): ConductorConfig {
1105
1115
  });
1106
1116
  }
1107
1117
 
1118
+ // Cross-field, and checked on the EFFECTIVE caps each project will run
1119
+ // under (#851): a per-run reservation larger than the day's budget can never
1120
+ // be satisfied, so every candidate would be held forever by a gate the
1121
+ // operator believed was a ceiling. Checked after resolution because the two
1122
+ // values may come from different layers — a global `dailySpendUsd` and a
1123
+ // per-project `maxRunSpendUsd`.
1124
+ for (const project of projects) {
1125
+ const effective = resolveCaps(project, defaults);
1126
+ if (
1127
+ effective.maxRunSpendUsd !== null &&
1128
+ effective.dailySpendUsd !== null &&
1129
+ effective.maxRunSpendUsd > effective.dailySpendUsd
1130
+ ) {
1131
+ problems.push(
1132
+ `project "${project.name}": caps.maxRunSpendUsd ($${String(effective.maxRunSpendUsd)}) must not exceed ` +
1133
+ `caps.dailySpendUsd ($${String(effective.dailySpendUsd)}) — a per-run reservation larger than the day's ` +
1134
+ "budget can never be admitted",
1135
+ );
1136
+ }
1137
+ }
1138
+
1108
1139
  const rawHost = root["host"] as Raw | undefined;
1109
1140
  const host = finalizeHost(rawHost);
1110
1141
  const rawDbBackupDir = root["dbBackupDir"];
@@ -1228,6 +1259,34 @@ function finalizeProject(
1228
1259
  ? rawThreshold
1229
1260
  : undefined;
1230
1261
 
1262
+ // The one-shot cap escalation target (#807): a hint like `workerModel`, and
1263
+ // trimmed here because it is handed to the harness verbatim as a selector.
1264
+ // Blank or the wrong type is dropped rather than failing the load, which is
1265
+ // exactly the "no escalation target configured" state settlement reports.
1266
+ const rawEscalationModel = p["workerEscalationModel"];
1267
+ const workerEscalationModel =
1268
+ typeof rawEscalationModel === "string" && rawEscalationModel.trim() !== ""
1269
+ ? rawEscalationModel.trim()
1270
+ : undefined;
1271
+
1272
+ // Providers whose routes must bill to a subscription credential (#852).
1273
+ // Trimmed and de-duplicated here because each entry is compared against the
1274
+ // provider id the harness itself reports, and a stray blank or a repeat would
1275
+ // spend a probe subprocess proving the same thing twice. A non-array, or an
1276
+ // entry of the wrong type, is dropped like an unusable `modelFallbacks` entry:
1277
+ // absent means today's dispatch, byte for byte.
1278
+ const rawRequireOauth = p["requireOauthProviders"];
1279
+ const requireOauthProviders = Array.isArray(rawRequireOauth)
1280
+ ? [
1281
+ ...new Set(
1282
+ rawRequireOauth
1283
+ .filter((entry): entry is string => typeof entry === "string")
1284
+ .map((entry) => entry.trim())
1285
+ .filter((entry) => entry !== ""),
1286
+ ),
1287
+ ]
1288
+ : [];
1289
+
1231
1290
  // The fleet-owned omp settings overlay (#537): an opaque map omp's own schema
1232
1291
  // owns, so the loader validates YAML shape only — a non-mapping is dropped
1233
1292
  // like an unusable `modelFallbacks` entry rather than failing the load, and
@@ -1299,6 +1358,8 @@ function finalizeProject(
1299
1358
  ...(workerModel === undefined ? {} : { workerModel }),
1300
1359
  ...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
1301
1360
  ...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
1361
+ ...(workerEscalationModel === undefined ? {} : { workerEscalationModel }),
1362
+ ...(requireOauthProviders.length === 0 ? {} : { requireOauthProviders }),
1302
1363
  ...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
1303
1364
  escalation,
1304
1365
  arm,
@@ -1594,6 +1655,25 @@ function reconcileCaps(
1594
1655
  if (cap !== undefined) out.planUsage = cap;
1595
1656
  continue;
1596
1657
  }
1658
+ if (key === "maxRunSpendUsd") {
1659
+ // Positive or null, never 0 (#851): `dailySpendUsd: 0` is a meaningful
1660
+ // hard stop, but a per-run allowance of 0 reserves nothing and admits
1661
+ // nothing — it is a misconfiguration wearing a cap's clothes, so it is
1662
+ // named rather than obeyed.
1663
+ if (v === null) {
1664
+ out.maxRunSpendUsd = null;
1665
+ continue;
1666
+ }
1667
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) {
1668
+ out.maxRunSpendUsd = v;
1669
+ continue;
1670
+ }
1671
+ problems.push(
1672
+ `${label}.maxRunSpendUsd must be a positive finite number or null (derive it from dailySpendUsd), ` +
1673
+ `found ${JSON.stringify(v)}`,
1674
+ );
1675
+ continue;
1676
+ }
1597
1677
  if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
1598
1678
  (out as Record<string, unknown>)[key as string] = v;
1599
1679
  continue;
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The credential-class fence (#852).
3
+ *
4
+ * On 2026-08-21 an Anthropic OAuth credential's refresh failed with
5
+ * `invalid_grant`. The exact selector `anthropic/claude-opus-5` still resolved —
6
+ * correct provider, correct model — but through the *other* Anthropic
7
+ * credential, an API key. One run then spent an estimated $25.16 against
8
+ * API-key billing while the operator expected subscription billing.
9
+ *
10
+ * ## Why conductor has to own this
11
+ *
12
+ * The harness cannot express it. `AuthStorage.getApiKey`
13
+ * (`pi-ai/src/auth-storage.ts:5290-5356`, installed 17.2.10) resolves
14
+ * first-match-wins across runtime override → config override → stored OAuth →
15
+ * login-sourced API key → provider env var → other stored API key → fallback
16
+ * resolver, and **an exact `provider/model` selector does not stop that
17
+ * cascade**. A definitive OAuth refresh failure disables that row
18
+ * (`auth-storage.ts:5162-5232`) and the same call descends into the API-key
19
+ * legs. Three surfaces that look like levers are not:
20
+ *
21
+ * - `models.yml` `auth: "oauth"` forces OAuth-style request *shaping* and sets
22
+ * `isOAuth` metadata (`model-registry.ts:636-690`); it selects nothing, and a
23
+ * custom model still requires an `apiKey` unless `auth: none`.
24
+ * - A provider-id variant is not a class pin: `xai-oauth` is a real distinct
25
+ * provider whose own descriptor also accepts `XAI_API_KEY`
26
+ * (`pi-catalog/src/provider-models/descriptors.ts:485-493`), and
27
+ * `anthropic-oauth` does not exist — Anthropic's id is `anthropic`, whose env
28
+ * resolver accepts both sources (`pi-ai/src/registry/anthropic.ts:7-26`).
29
+ * - No setting, env var, selector suffix or request option expresses it:
30
+ * `AuthApiKeyOptions` is `{ baseUrl, modelId, signal, forceRefresh }`
31
+ * (`auth-storage.ts:796-813`).
32
+ *
33
+ * ## Why this is a pre-launch fence and not an audit
34
+ *
35
+ * There is no after-the-fact answer to "which class served that request".
36
+ * `getApiKey` returns `string | undefined` and never names the class it chose
37
+ * (`auth-storage.ts:5302`); `AssistantMessage` carries no credential field
38
+ * (`pi-ai/src/types.ts:858-912`); and session `credential_pin` entries are
39
+ * OAuth-only *and* change-only (`session-entries.ts:185-202`), so steady OAuth
40
+ * writes nothing, API-key use writes nothing, and neither a stale pin nor a
41
+ * missing one proves anything. A transcript audit of billing class would be a
42
+ * fabrication, so this module does not attempt one.
43
+ *
44
+ * What the harness *does* expose, token-free, is its current state — and that is
45
+ * enough to refuse before spending anything.
46
+ *
47
+ * ## The residue, stated rather than papered over
48
+ *
49
+ * This is a state check, not an atomic guarantee. If OAuth becomes invalid
50
+ * *during* a request, the installed resolver may still disable it and fall
51
+ * through to a same-provider API key, and nothing conductor can pass prevents
52
+ * that. The only hard lever is omp's own `disabledProviders`, which removes the
53
+ * provider before credential checks — and also removes the subscription route,
54
+ * which is why it stays an operator action rather than conductor's automatic
55
+ * response. The honest fix is upstream: an `AuthApiKeyOptions` that can require a
56
+ * credential class and refuse rather than descend.
57
+ *
58
+ * ## Why the requirement is per provider, never per run
59
+ *
60
+ * A credential belongs to a provider, not to a model — a model has none of its
61
+ * own. And a run's provider is not knowable in advance: `workerModel` is an
62
+ * opaque omp selector (it may be a role alias), and the provider-fault fallback
63
+ * chain (#286) can move a live run onto another provider mid-flight. So a
64
+ * per-run provider inference would be a guess dressed as a fact. The declared
65
+ * unit is therefore "this provider must bill to its subscription", and the
66
+ * verdict is the same for every candidate while that is untrue.
67
+ */
68
+
69
+ import { join } from "node:path";
70
+
71
+ /**
72
+ * The harness's own answer about one provider, as read through its exported
73
+ * API. Deliberately narrow: an origin kind and disabled-credential *causes*, and
74
+ * nothing that could carry credential material.
75
+ */
76
+ export interface CredentialClassProbe {
77
+ provider: string;
78
+ /**
79
+ * `getCredentialOrigin(provider)` — the currently winning source
80
+ * (`auth-storage.ts:2689-2714`). Absent means the provider resolves to nothing
81
+ * at all, which is a different refusal from "resolves to the wrong class".
82
+ */
83
+ origin?: { kind: string; envVar?: string };
84
+ /**
85
+ * `listDisabledCredentials(provider)` (`auth-storage.ts:6235-6247`), reduced to
86
+ * class and cause. The cause is what makes a refusal actionable — an operator
87
+ * reading `invalid_grant` knows to re-authenticate — and it is not secret.
88
+ */
89
+ disabled: readonly { type: string; cause: string }[];
90
+ }
91
+
92
+ /** Why a fence refused, or that it did not. */
93
+ export type CredentialClassVerdict =
94
+ | { ok: true }
95
+ | { ok: false; reason: string };
96
+
97
+ /** The runnable probe's own output: one provider per request, or the failure. */
98
+ export type CredentialClassProbeResult =
99
+ | { ok: true; probe: CredentialClassProbe }
100
+ | { ok: false; reason: string };
101
+
102
+ /**
103
+ * Does `provider` currently bill to its subscription?
104
+ *
105
+ * Pure, so the whole decision is testable without a credential store, a
106
+ * subprocess or a harness — which matters because this is the function whose
107
+ * wrong answer costs either a fleet stall or a surprise invoice.
108
+ *
109
+ * The gate is `origin.kind === "oauth"` and nothing looser. In particular
110
+ * `env` is refused even when the environment variable is an OAuth *token*
111
+ * (`ANTHROPIC_OAUTH_TOKEN` is one): conductor cannot see whether that value is a
112
+ * subscription token or a key, and a fence that guesses is not a fence.
113
+ */
114
+ export function oauthRequirementVerdict(probe: CredentialClassProbe): CredentialClassVerdict {
115
+ const { provider, origin } = probe;
116
+ // The disabled row is not the verdict — it is the remediation. Reported for
117
+ // any refusal, because "OAuth is disabled because the refresh returned
118
+ // invalid_grant" is the difference between an operator re-authenticating in a
119
+ // minute and an operator reading source for an hour.
120
+ const disabledOauth = probe.disabled.filter((row) => row.type === "oauth");
121
+ const because =
122
+ disabledOauth.length === 0
123
+ ? ""
124
+ : ` The ${provider} OAuth credential is disabled: ${disabledOauth
125
+ .map((row) => row.cause)
126
+ .join("; ")}.`;
127
+ const remedy =
128
+ ` Re-authenticate ${provider} (\`omp auth login ${provider}\`), or take the whole provider out of` +
129
+ ` play with omp's own disabledProviders — conductor will not dispatch onto an API key it was told` +
130
+ " not to use.";
131
+
132
+ if (origin === undefined) {
133
+ return {
134
+ ok: false,
135
+ reason:
136
+ `${provider} requires subscription (OAuth) credentials, and no ${provider} credential resolves at` +
137
+ ` all.${because}${remedy}`,
138
+ };
139
+ }
140
+ if (origin.kind === "oauth") return { ok: true };
141
+ const named =
142
+ origin.kind === "env" && origin.envVar !== undefined
143
+ ? `an environment variable (${origin.envVar})`
144
+ : `a ${origin.kind} credential`;
145
+ return {
146
+ ok: false,
147
+ reason:
148
+ `${provider} requires subscription (OAuth) credentials, but it currently resolves to ${named}, so a` +
149
+ ` dispatch would bill to that instead.${because}${remedy}`,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Every declared provider's verdict, in declaration order, refusing on the first
155
+ * that fails.
156
+ *
157
+ * One refusal at a time on purpose: a hold names the one thing to fix, and a
158
+ * concatenation of provider problems reads as a configuration essay rather than
159
+ * an action.
160
+ */
161
+ export async function oauthFenceVerdict(
162
+ providers: readonly string[],
163
+ probe: (provider: string) => Promise<CredentialClassProbeResult>,
164
+ ): Promise<CredentialClassVerdict> {
165
+ for (const provider of providers) {
166
+ const result = await probe(provider);
167
+ if (!result.ok) {
168
+ // A probe that cannot answer is a refusal, not a pass. The whole point of
169
+ // the fence is that the expensive outcome is dispatching blind, and
170
+ // "we could not check" is indistinguishable from "it is wrong" in cost.
171
+ return {
172
+ ok: false,
173
+ reason:
174
+ `${provider} requires subscription (OAuth) credentials and that could not be verified: ` +
175
+ `${result.reason}. Refusing rather than dispatching unverified.`,
176
+ };
177
+ }
178
+ const verdict = oauthRequirementVerdict(result.probe);
179
+ if (!verdict.ok) return verdict;
180
+ }
181
+ return { ok: true };
182
+ }
183
+
184
+ /** How the probe subprocess is run, injected so tests need no harness. */
185
+ export interface CredentialProbeDeps {
186
+ run?: (args: readonly string[]) => Promise<{ code: number; stdout: string; stderr: string }>;
187
+ /** This module's own path, so the child runs the same file it was built with. */
188
+ modulePath?: string;
189
+ }
190
+
191
+ const REAL_RUN = async (
192
+ args: readonly string[],
193
+ ): Promise<{ code: number; stdout: string; stderr: string }> => {
194
+ const child = Bun.spawn([...args], {
195
+ stdin: "ignore",
196
+ stdout: "pipe",
197
+ stderr: "pipe",
198
+ env: process.env,
199
+ });
200
+ const stdout = new Response(child.stdout).text();
201
+ const stderr = new Response(child.stderr).text();
202
+ const code = await child.exited;
203
+ return { code, stdout: await stdout, stderr: await stderr };
204
+ };
205
+
206
+ /**
207
+ * Ask the harness about one provider, out of process.
208
+ *
209
+ * Out of process for two reasons, and the second is the load-bearing one. It
210
+ * keeps the harness's module graph and its credential database out of the
211
+ * long-lived daemon; and it means every read is taken *now*, against whatever
212
+ * the harness currently believes, rather than against a pool this process
213
+ * loaded once and cached. A fence reading a stale in-memory pool would pass a
214
+ * credential that was disabled an hour ago.
215
+ *
216
+ * This mirrors `harness-loader.ts` exactly — a runnable module, spawned with
217
+ * `--no-install`, whose stdout is one JSON line — because that is already how
218
+ * this package asks the installed harness a question it must not answer itself.
219
+ */
220
+ export async function probeCredentialClass(
221
+ provider: string,
222
+ deps: CredentialProbeDeps = {},
223
+ ): Promise<CredentialClassProbeResult> {
224
+ const run = deps.run ?? REAL_RUN;
225
+ const modulePath = deps.modulePath ?? join(import.meta.dir, "credential-class.ts");
226
+ let result: { code: number; stdout: string; stderr: string };
227
+ try {
228
+ result = await run(["bun", "--no-install", modulePath, provider]);
229
+ } catch (err) {
230
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
231
+ }
232
+ if (result.code !== 0) {
233
+ const detail = (result.stderr.trim() || result.stdout.trim()).split("\n")[0] ?? `exit ${result.code}`;
234
+ return { ok: false, reason: detail };
235
+ }
236
+ return parseProbeOutput(provider, result.stdout);
237
+ }
238
+
239
+ /**
240
+ * The child's stdout, validated rather than trusted.
241
+ *
242
+ * Exported because the parse is where a malformed answer either becomes a clean
243
+ * refusal or becomes a fence that silently passes. Anything unexpected — not
244
+ * JSON, wrong provider, missing `disabled` array — is a refusal.
245
+ */
246
+ export function parseProbeOutput(provider: string, stdout: string): CredentialClassProbeResult {
247
+ let parsed: unknown;
248
+ try {
249
+ parsed = JSON.parse(stdout.trim());
250
+ } catch {
251
+ return { ok: false, reason: "the credential probe did not answer with JSON" };
252
+ }
253
+ if (parsed === null || typeof parsed !== "object") {
254
+ return { ok: false, reason: "the credential probe answered with no object" };
255
+ }
256
+ const answeredFor = Reflect.get(parsed, "provider");
257
+ if (answeredFor !== provider) {
258
+ // Never accept an answer about a provider nobody asked about: that is how a
259
+ // fence comes to approve the wrong route.
260
+ return {
261
+ ok: false,
262
+ reason: `the credential probe answered for ${String(answeredFor)}, not ${provider}`,
263
+ };
264
+ }
265
+ const rawDisabled = Reflect.get(parsed, "disabled");
266
+ if (!Array.isArray(rawDisabled)) {
267
+ return { ok: false, reason: "the credential probe reported no disabled-credential list" };
268
+ }
269
+ const disabled: { type: string; cause: string }[] = [];
270
+ for (const row of rawDisabled) {
271
+ if (row === null || typeof row !== "object") continue;
272
+ const type = Reflect.get(row, "type");
273
+ const cause = Reflect.get(row, "cause");
274
+ if (typeof type !== "string") continue;
275
+ disabled.push({ type, cause: typeof cause === "string" ? cause : "no cause recorded" });
276
+ }
277
+ const rawOrigin = Reflect.get(parsed, "origin");
278
+ let origin: { kind: string; envVar?: string } | undefined;
279
+ if (rawOrigin !== null && typeof rawOrigin === "object") {
280
+ const kind = Reflect.get(rawOrigin, "kind");
281
+ const envVar = Reflect.get(rawOrigin, "envVar");
282
+ if (typeof kind === "string") {
283
+ origin = { kind, ...(typeof envVar === "string" ? { envVar } : {}) };
284
+ }
285
+ }
286
+ return { ok: true, probe: { provider, ...(origin === undefined ? {} : { origin }), disabled } };
287
+ }
288
+
289
+ /**
290
+ * The two harness shapes this module needs, declared here rather than inlined.
291
+ *
292
+ * The harness is a *peer* dependency: it is absent from this package's own
293
+ * install and resolves from wherever the operator installed omp. Its types are
294
+ * therefore not available to `tsc` here, so the surface conductor relies on is
295
+ * written down — which is better documentation than an inferred type anyway,
296
+ * because it is exactly the contract that would break on a harness upgrade.
297
+ */
298
+ interface HarnessAuthStorage {
299
+ reload?: () => Promise<void>;
300
+ getCredentialOrigin: (provider: string) => { kind: string; envVar?: string } | undefined;
301
+ listDisabledCredentials: (provider?: string) => Promise<readonly { type: string; cause: string }[]>;
302
+ }
303
+
304
+ interface HarnessAuthModule {
305
+ AuthStorage: new (store: unknown) => HarnessAuthStorage;
306
+ SqliteAuthCredentialStore: new (db: unknown) => { close: () => void };
307
+ }
308
+
309
+ interface HarnessPathsModule {
310
+ getAgentDbPath: (agentDir?: string) => string;
311
+ }
312
+
313
+ /**
314
+ * The child half: read the harness's state for one provider and print it.
315
+ *
316
+ * Everything here is the harness's own exported API — never a query against
317
+ * `agent.db`, whose schema is not conductor's to know. Nothing printed can carry
318
+ * credential material: an origin kind, an optional environment-variable *name*,
319
+ * and disabled classes with their causes.
320
+ *
321
+ * The imports are dynamic and their specifiers resolved at runtime because they
322
+ * genuinely cannot be static: the harness is an absent peer at build time (a
323
+ * literal specifier would fail `tsc`), it must resolve from *this package's*
324
+ * install root rather than Bun's ambient cache — the same anchoring
325
+ * `harness-loader.ts` exists to guarantee — and loading it at module scope would
326
+ * pull the whole harness and its credential database into the daemon, which is
327
+ * the thing running the probe out of process avoids.
328
+ */
329
+ if (import.meta.main) {
330
+ const provider = process.argv[2] ?? "";
331
+ try {
332
+ if (provider === "") throw new Error("usage: credential-class.ts <provider>");
333
+ const { Database } = await import("bun:sqlite");
334
+ const auth = (await import(
335
+ Bun.resolveSync("@oh-my-pi/pi-coding-agent/session/auth-storage", import.meta.dir)
336
+ )) as HarnessAuthModule;
337
+ // The agent database's location is the harness's convention, taken from the
338
+ // harness — hardcoding `~/.omp/agent/agent.db` here would be a second
339
+ // opinion about where credentials live.
340
+ const paths = (await import(
341
+ Bun.resolveSync("@oh-my-pi/pi-utils", import.meta.dir)
342
+ )) as HarnessPathsModule;
343
+ const store = new auth.SqliteAuthCredentialStore(new Database(paths.getAgentDbPath()));
344
+ try {
345
+ const storage = new auth.AuthStorage(store);
346
+ // `reload` is what makes this read current rather than constructor-fresh:
347
+ // it rebuilds the active pool from the store, so a credential disabled
348
+ // since the row was written is absent here exactly as it is for the
349
+ // resolver that would have used it.
350
+ await storage.reload?.();
351
+ const origin = storage.getCredentialOrigin(provider);
352
+ const disabled = (await storage.listDisabledCredentials(provider)).map((row) => ({
353
+ type: row.type,
354
+ cause: row.cause,
355
+ }));
356
+ process.stdout.write(
357
+ `${JSON.stringify({ provider, ...(origin === undefined ? {} : { origin }), disabled })}\n`,
358
+ );
359
+ } finally {
360
+ store.close();
361
+ }
362
+ } catch (cause) {
363
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
364
+ process.exitCode = 1;
365
+ }
366
+ }