@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile, execFileSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { delimiter, join } from "node:path";
@@ -70,11 +70,18 @@ export type RuntimeExecFile = (
70
70
  },
71
71
  ) => string;
72
72
 
73
+ /** Async twin of RuntimeExecFile, used by resolveCodexRuntimeAsync; resolves with stdout. */
74
+ export type RuntimeExecFileAsync = (
75
+ ...args: Parameters<RuntimeExecFile>
76
+ ) => Promise<string>;
77
+
73
78
  export interface ResolveCodexRuntimeDeps {
74
79
  env?: NodeJS.ProcessEnv;
75
80
  platform?: NodeJS.Platform;
76
81
  configDir?: string;
77
82
  execFileSync?: RuntimeExecFile;
83
+ /** Probe exec for resolveCodexRuntimeAsync. Injected impls bypass the process memo like execFileSync. */
84
+ execFile?: RuntimeExecFileAsync;
78
85
  existsSync?: (path: string) => boolean;
79
86
  readFileSync?: (path: string, encoding: "utf8") => string;
80
87
  now?: () => number;
@@ -397,10 +404,10 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}):
397
404
  }
398
405
  }
399
406
 
400
- function probeVersion(
401
- command: string,
402
- deps: ResolveCodexRuntimeDeps,
403
- ): { ok: true; version: string | null } | { ok: false; reason: string } {
407
+ type VersionProbeResult = { ok: true; version: string | null } | { ok: false; reason: string };
408
+
409
+ /** Checks that need no exec; a non-null answer settles the candidate without running `--version`. */
410
+ function versionProbePrecheck(command: string, deps: ResolveCodexRuntimeDeps): VersionProbeResult | null {
404
411
  const platform = deps.platform ?? process.platform;
405
412
  if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) {
406
413
  const exists = deps.existsSync ?? existsSync;
@@ -414,7 +421,54 @@ function probeVersion(
414
421
  // dashboard probe that cost made Windows report Codex as missing even when
415
422
  // the App install was sitting under LOCALAPPDATA/OpenAI/Codex/bin (issue 4458).
416
423
  if (deps.probeVersion === false) return { ok: true, version: null };
417
- const execFile = deps.execFileSync ?? (execFileSync as unknown as RuntimeExecFile);
424
+ return null;
425
+ }
426
+
427
+ function versionProbeInvocation(command: string, deps: ResolveCodexRuntimeDeps, probeHome: string) {
428
+ const invocation = codexExecInvocation(command, ["--version"], deps.platform ?? process.platform, {
429
+ env: deps.env,
430
+ exists: deps.existsSync,
431
+ });
432
+ return {
433
+ file: invocation.file,
434
+ args: invocation.args,
435
+ options: {
436
+ encoding: "utf8" as const,
437
+ stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
438
+ timeout: 8_000,
439
+ windowsHide: true,
440
+ env: { ...(deps.env ?? process.env), CODEX_HOME: probeHome },
441
+ ...invocation.options,
442
+ },
443
+ };
444
+ }
445
+
446
+ function versionProbeOutput(output: string): VersionProbeResult {
447
+ const version = parseCodexVersionOutput(output);
448
+ if (!version) return { ok: false, reason: "unrecognized --version output" };
449
+ return { ok: true, version };
450
+ }
451
+
452
+ function versionProbeError(error: unknown, probeHome: string | undefined): VersionProbeResult {
453
+ if (!probeHome) return { ok: false, reason: "probe sandbox unavailable" };
454
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
455
+ return { ok: false, reason: CODEX_PROGRAM_NOT_FOUND_REASON };
456
+ }
457
+ const message = error instanceof Error ? error.message : String(error);
458
+ const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
459
+ return { ok: false, reason: `failed --version (${redacted})` };
460
+ }
461
+
462
+ function removeProbeHome(probeHome: string | undefined): void {
463
+ if (!probeHome) return;
464
+ // Nested catch: a transient Windows EBUSY must never mask the probe result.
465
+ try { rmSync(probeHome, { recursive: true, force: true }); } catch { /* best effort */ }
466
+ }
467
+
468
+ function probeVersion(command: string, deps: ResolveCodexRuntimeDeps): VersionProbeResult {
469
+ const settled = versionProbePrecheck(command, deps);
470
+ if (settled) return settled;
471
+ const exec = deps.execFileSync ?? (execFileSync as unknown as RuntimeExecFile);
418
472
  // Sandbox the probe's CODEX_HOME: a real Codex CLI creates state (tmp/, logs) under
419
473
  // CODEX_HOME even for `--version`, and the probe inherits the caller's env — so a
420
474
  // read-only `ocx status` would dirty the user's CODEX_HOME. Redirect it to a
@@ -423,36 +477,36 @@ function probeVersion(
423
477
  let probeHome: string | undefined;
424
478
  try {
425
479
  probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-"));
426
- const invocation = codexExecInvocation(command, ["--version"], platform, {
427
- env: deps.env,
428
- exists: deps.existsSync,
429
- });
430
- const output = execFile(invocation.file, invocation.args, {
431
- encoding: "utf8",
432
- stdio: ["ignore", "pipe", "ignore"],
433
- timeout: 8_000,
434
- windowsHide: true,
435
- env: { ...(deps.env ?? process.env), CODEX_HOME: probeHome },
436
- ...invocation.options,
437
- });
438
- const version = parseCodexVersionOutput(output);
439
- if (!version) {
440
- return { ok: false, reason: "unrecognized --version output" };
441
- }
442
- return { ok: true, version };
480
+ const { file, args, options } = versionProbeInvocation(command, deps, probeHome);
481
+ return versionProbeOutput(exec(file, args, options));
443
482
  } catch (error) {
444
- if (!probeHome) return { ok: false, reason: "probe sandbox unavailable" };
445
- if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
446
- return { ok: false, reason: CODEX_PROGRAM_NOT_FOUND_REASON };
447
- }
448
- const message = error instanceof Error ? error.message : String(error);
449
- const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
450
- return { ok: false, reason: `failed --version (${redacted})` };
483
+ return versionProbeError(error, probeHome);
451
484
  } finally {
452
- if (probeHome) {
453
- // Nested catch: a transient Windows EBUSY must never mask the probe result.
454
- try { rmSync(probeHome, { recursive: true, force: true }); } catch { /* best effort */ }
455
- }
485
+ removeProbeHome(probeHome);
486
+ }
487
+ }
488
+
489
+ const defaultExecFileAsync: RuntimeExecFileAsync = (file, args, options) =>
490
+ new Promise((resolve, reject) => {
491
+ // execFile always pipes stdout/stderr; it takes no stdio option.
492
+ const { stdio: _stdio, ...execOptions } = options;
493
+ execFile(file, args, execOptions, (error, stdout) => (error ? reject(error) : resolve(String(stdout))));
494
+ });
495
+
496
+ /** probeVersion with the same sandbox and outcomes, but the exec never holds the event loop. */
497
+ async function probeVersionAsync(command: string, deps: ResolveCodexRuntimeDeps): Promise<VersionProbeResult> {
498
+ const settled = versionProbePrecheck(command, deps);
499
+ if (settled) return settled;
500
+ const exec = deps.execFile ?? defaultExecFileAsync;
501
+ let probeHome: string | undefined;
502
+ try {
503
+ probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-"));
504
+ const { file, args, options } = versionProbeInvocation(command, deps, probeHome);
505
+ return versionProbeOutput(await exec(file, args, options));
506
+ } catch (error) {
507
+ return versionProbeError(error, probeHome);
508
+ } finally {
509
+ removeProbeHome(probeHome);
456
510
  }
457
511
  }
458
512
 
@@ -566,23 +620,6 @@ interface RankedCandidate {
566
620
  source: CodexRuntimeSource;
567
621
  }
568
622
 
569
- function tryCandidate(
570
- candidate: RankedCandidate,
571
- failures: RuntimeProbeFailure[],
572
- deps: ResolveCodexRuntimeDeps,
573
- ): ResolvedCodexRuntime | null {
574
- const probed = probeVersion(candidate.command, deps);
575
- if (!probed.ok) {
576
- failures.push({ command: candidate.command, source: candidate.source, reason: probed.reason });
577
- return null;
578
- }
579
- return {
580
- command: candidate.command,
581
- version: probed.version,
582
- source: candidate.source,
583
- };
584
- }
585
-
586
623
  function sameRuntimeCommand(a: string, b: string): boolean {
587
624
  return a.trim().toLowerCase() === b.trim().toLowerCase();
588
625
  }
@@ -704,10 +741,26 @@ export function clearCodexRuntimeResolveCache(): void {
704
741
  clearResolveCache();
705
742
  }
706
743
 
707
- /** Observe only an unexpired successful process memo; never resolves or probes. */
744
+ /**
745
+ * Observe the successful process memo; never resolves or probes.
746
+ *
747
+ * An expired memo stays observable while an async refresh for the same inputs is in flight
748
+ * and nothing has invalidated it since. The sync resolver used to hold the event loop for the
749
+ * whole probe, so nothing could read in that window; with the refresh off the loop, dropping
750
+ * the memo there would send catalog gather to the persisted runtime (or none) and have
751
+ * convergence reject its process-local candidate every expiry. A published result or a clear
752
+ * still retires it; the deferred selection never enters this memo (#4458).
753
+ */
708
754
  export function peekCodexRuntimeProcessCache(): CodexRuntimeProcessCachePeek {
709
755
  const memo = resolveCache;
710
- if (!memo || Date.now() - memo.at >= RESOLVE_CACHE_MS) {
756
+ const refreshing = memo !== null
757
+ && asyncResolveInflight !== null
758
+ && asyncResolveInflight.key === memo.key
759
+ && asyncResolveInflight.epoch === resolveCacheEpoch
760
+ // An expired memo only stands in for a refresh of the same selection; an out-of-process
761
+ // runtime switch changes the key without bumping the epoch.
762
+ && resolveCacheKey({}) === memo.key;
763
+ if (!memo || (Date.now() - memo.at >= RESOLVE_CACHE_MS && !refreshing)) {
711
764
  return Object.freeze({ kind: "unavailable" as const, epoch: resolveCacheEpoch });
712
765
  }
713
766
  return Object.freeze({
@@ -735,6 +788,7 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
735
788
  // Only memoize uninjected process-env resolves (settings/status hot paths).
736
789
  if (
737
790
  deps.execFileSync
791
+ || deps.execFile
738
792
  || deps.existsSync
739
793
  || deps.readFileSync
740
794
  || deps.readdirSync
@@ -799,6 +853,59 @@ export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): Resolve
799
853
  return cloneAndDeepFreeze(result);
800
854
  }
801
855
 
856
+ let asyncResolveInflight: { key: string; epoch: number; promise: Promise<ResolveCodexRuntimeResult> } | null = null;
857
+
858
+ /**
859
+ * resolveCodexRuntime for server request paths: same selection and process memo, but every
860
+ * `codex --version` runs through async exec. The sync resolver blocked the whole proxy for
861
+ * ~0.6s per memo expiry while the dashboard polled settings, stalling unrelated requests.
862
+ */
863
+ export async function resolveCodexRuntimeAsync(
864
+ deps: ResolveCodexRuntimeDeps = {},
865
+ ): Promise<ResolveCodexRuntimeResult> {
866
+ // A deferred selection never execs, so the sync path is already nonblocking.
867
+ if (deps.probeVersion === false) return resolveCodexRuntime(deps);
868
+ const cacheKey = resolveCacheKey(deps);
869
+ if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) {
870
+ return cloneAndDeepFreeze(resolveCache.value);
871
+ }
872
+ if (!cacheKey) return cloneAndDeepFreeze(await resolveCodexRuntimeUncachedAsync(deps));
873
+ const startedEpoch = resolveCacheEpoch;
874
+ if (asyncResolveInflight?.key === cacheKey && asyncResolveInflight.epoch === startedEpoch) {
875
+ return cloneAndDeepFreeze(await asyncResolveInflight.promise);
876
+ }
877
+ const promise = resolveCodexRuntimeUncachedAsync(deps).then(result => {
878
+ // A persist or clear while the probes ran makes this answer the previous selection's;
879
+ // publishing it would resurrect authority the write just revoked. Another process can also
880
+ // rewrite codex-runtime.json without touching this epoch, so the key is read again too.
881
+ if (resolveCacheEpoch === startedEpoch && resolveCacheKey(deps) === cacheKey) {
882
+ publishResolveCache(cacheKey, Date.now(), result);
883
+ }
884
+ return result;
885
+ }).finally(() => {
886
+ if (asyncResolveInflight?.promise === promise) asyncResolveInflight = null;
887
+ });
888
+ asyncResolveInflight = { key: cacheKey, epoch: startedEpoch, promise };
889
+ return cloneAndDeepFreeze(await promise);
890
+ }
891
+
892
+ /**
893
+ * Stale-while-revalidate runtime read for hot UI paths; never waits on a probe.
894
+ *
895
+ * Returns the process memo when fresh, otherwise the last memo for the same inputs, and kicks
896
+ * resolveCodexRuntimeAsync to refresh it. With no memo yet (cold start, or right after a runtime
897
+ * switch cleared it) the answer is the exec-free deferred selection, so a version and
898
+ * newerAvailable appear once the first background refresh lands.
899
+ */
900
+ export function getCodexRuntimeSnapshot(): ResolveCodexRuntimeResult {
901
+ const cacheKey = resolveCacheKey({});
902
+ const memo = resolveCache?.key === cacheKey ? resolveCache : null;
903
+ if (memo && Date.now() - memo.at < RESOLVE_CACHE_MS) return cloneAndDeepFreeze(memo.value);
904
+ resolveCodexRuntimeAsync().catch(() => { /* the next read retries */ });
905
+ if (memo) return cloneAndDeepFreeze(memo.value);
906
+ return resolveCodexRuntime({ discoverAlternatives: false, probeVersion: false });
907
+ }
908
+
802
909
  /** Test-only: drop the short-lived process resolve cache. */
803
910
  export function resetCodexRuntimeResolveCacheForTests(): void {
804
911
  clearCodexRuntimeResolveCache();
@@ -814,7 +921,14 @@ export function setCodexRuntimeResolveCacheForTests(
814
921
  publishResolveCache(key, Date.now(), value);
815
922
  }
816
923
 
817
- function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
924
+ /**
925
+ * Candidate ranking and selection, written once for both drivers: each yield hands a candidate to
926
+ * the caller, which sends back its `--version` outcome. The sync driver keeps CLI behavior; the
927
+ * async one lets a server path resolve without blocking every other request on the probes.
928
+ */
929
+ function* resolveCodexRuntimeSteps(
930
+ deps: ResolveCodexRuntimeDeps,
931
+ ): Generator<RankedCandidate, ResolveCodexRuntimeResult, VersionProbeResult> {
818
932
  const env = deps.env ?? process.env;
819
933
  const failures: RuntimeProbeFailure[] = [];
820
934
  const ordered: RankedCandidate[] = [];
@@ -868,9 +982,12 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
868
982
  ) {
869
983
  continue;
870
984
  }
871
- const resolved = tryCandidate(candidate, failures, deps);
872
- if (!resolved) continue;
873
- valid.push(resolved);
985
+ const probed: VersionProbeResult = yield candidate;
986
+ if (!probed.ok) {
987
+ failures.push({ command: candidate.command, source: candidate.source, reason: probed.reason });
988
+ continue;
989
+ }
990
+ valid.push({ command: candidate.command, version: probed.version, source: candidate.source });
874
991
  }
875
992
 
876
993
  if (valid.length === 0) {
@@ -954,6 +1071,20 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
954
1071
  };
955
1072
  }
956
1073
 
1074
+ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
1075
+ const steps = resolveCodexRuntimeSteps(deps);
1076
+ let step = steps.next();
1077
+ while (!step.done) step = steps.next(probeVersion(step.value.command, deps));
1078
+ return step.value;
1079
+ }
1080
+
1081
+ async function resolveCodexRuntimeUncachedAsync(deps: ResolveCodexRuntimeDeps): Promise<ResolveCodexRuntimeResult> {
1082
+ const steps = resolveCodexRuntimeSteps(deps);
1083
+ let step = steps.next();
1084
+ while (!step.done) step = steps.next(await probeVersionAsync(step.value.command, deps));
1085
+ return step.value;
1086
+ }
1087
+
957
1088
  /** Resolve and persist a successful selection (unless source is ephemeral fallback-only with no path). */
958
1089
  export function resolveAndPersistCodexRuntime(
959
1090
  deps: ResolveCodexRuntimeDeps = {},
@@ -0,0 +1,71 @@
1
+ /** A one-use restart handoff from a running sibling to its own replacement. */
2
+ import { randomBytes } from "node:crypto";
3
+ import { lstatSync, readFileSync, realpathSync, renameSync, unlinkSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { atomicWriteFile } from "../config/atomic-write";
6
+ import { getConfigDir } from "../config/paths";
7
+ import { readRuntimePort } from "../config/process-state";
8
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
9
+ import { siblingOfLivePort } from "./sibling-start";
10
+
11
+ const NONCE_RE = /^[A-Za-z0-9_-]{43}$/;
12
+ const MAX_HANDOFF_AGE_MS = 2 * 60_000;
13
+ const MAX_RECORD_BYTES = 512;
14
+
15
+ function handoffPath(home: string, nonce: string): string {
16
+ return join(home, `sibling-handoff-${nonce}.json`);
17
+ }
18
+
19
+ /** Issue only while this home's runtime record still identifies the running sibling. */
20
+ export function issueSiblingHandoff(ownerPort: number): string {
21
+ const home = getConfigDir();
22
+ assertNotRealHomeUnderTest(home);
23
+ const runtime = readRuntimePort(process.pid);
24
+ if (siblingOfLivePort() !== ownerPort || !runtime
25
+ || runtime.siblingOfPort !== ownerPort || runtime.port === ownerPort) {
26
+ throw new Error("Cannot hand off sibling status without this home's live sibling record.");
27
+ }
28
+ const nonce = randomBytes(32).toString("base64url");
29
+ const record = {
30
+ version: 1,
31
+ nonce,
32
+ home: realpathSync(home),
33
+ issuerPid: process.pid,
34
+ issuerPort: runtime.port,
35
+ ownerPort,
36
+ issuedAt: Date.now(),
37
+ };
38
+ atomicWriteFile(handoffPath(home, nonce), JSON.stringify(record));
39
+ return nonce;
40
+ }
41
+
42
+ /** Atomically claim and consume the record; a copied env cannot replay it. */
43
+ export function consumeSiblingHandoff(ownerPort: number, nonce: string | undefined): boolean {
44
+ if (!nonce || !NONCE_RE.test(nonce)) return false;
45
+ const home = getConfigDir();
46
+ let canonicalHome: string;
47
+ try { canonicalHome = realpathSync(home); }
48
+ catch { return false; }
49
+ const source = handoffPath(home, nonce);
50
+ const claimed = `${source}.claimed-${process.pid}`;
51
+ try { renameSync(source, claimed); }
52
+ catch { return false; }
53
+ try {
54
+ const stat = lstatSync(claimed);
55
+ if (!stat.isFile() || stat.size > MAX_RECORD_BYTES || stat.size === 0
56
+ || (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()))) return false;
57
+ const record = JSON.parse(readFileSync(claimed, "utf8")) as Record<string, unknown>;
58
+ const issuedAt = record.issuedAt;
59
+ const now = Date.now();
60
+ return record.version === 1 && record.nonce === nonce && record.home === canonicalHome
61
+ && record.ownerPort === ownerPort && Number.isSafeInteger(record.issuerPid)
62
+ && Number(record.issuerPid) > 0 && Number.isInteger(record.issuerPort)
63
+ && Number(record.issuerPort) > 0 && Number(record.issuerPort) <= 65535
64
+ && record.issuerPort !== ownerPort && typeof issuedAt === "number"
65
+ && issuedAt <= now && now - issuedAt <= MAX_HANDOFF_AGE_MS;
66
+ } catch {
67
+ return false;
68
+ } finally {
69
+ try { unlinkSync(claimed); } catch { /* already consumed */ }
70
+ }
71
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The process-local "sibling instance" mark.
3
+ *
4
+ * A sibling is what `decideStartWithLiveOwner` calls `"sibling"`: `ocx start --port <other>` while a
5
+ * live proxy already serves the configured port. Only an independent `OPENCODEX_HOME` gets past the
6
+ * state-directory spend-ledger lease, and that independence ends at its own directory: the sibling
7
+ * still shares `CODEX_HOME`, `~/.claude`, `~/.grok`, the shell rc hook and the launchd domain with
8
+ * the live owner. A sibling that ran the ordinary startup sync therefore re-pointed Codex at its own
9
+ * port, and every thread broke with "Connection refused" once the sibling exited or was killed. The
10
+ * owner's client routing is the owner's, for this process's whole lifetime.
11
+ *
12
+ * So `handleStart` sets this mark the moment it takes the sibling path, before the server binds and
13
+ * before any client write, and the local-client gate (`localClientSyncAllowed` in
14
+ * `desired-state.ts`), the restore entry points, the catalog convergence funnel, the owned-catalog
15
+ * refresh, the Claude writers, the stop teardown and the management route guard all consult it. The
16
+ * sibling still serves direct requests on its own port.
17
+ *
18
+ * One-way and process-local on purpose. A mark that could be cleared would let a later code path
19
+ * resume writing mid-lifetime, and a persisted one would outlive the process it describes. Worker
20
+ * threads do not see it; that is acceptable because history jobs start only from inject and
21
+ * restore, and both return before spawning one.
22
+ *
23
+ * A sibling runs the no-op native-main lifecycle (the gate is closed), so it never contends for the
24
+ * native-main owner lease and cannot take ownership from the live proxy across that proxy's
25
+ * restarts. Its data-plane native-main admission is the same as a Codex-OFF proxy's: an `auth.json`
26
+ * refresh still runs under the machine-wide exclusive claim. The native-main mutation routes are
27
+ * refused by the management guard instead.
28
+ *
29
+ * The one hand-off is to this process's own replacement. A sibling's dashboard drain-and-restart and
30
+ * its standalone recycle spawn a fresh `ocx start` that re-probes; if the owner is down for that
31
+ * moment, the replacement used to start as an unmarked owner, re-point Codex at itself and persist
32
+ * `config.port`. Those spawns carry a port plus a one-use record nonce from `sibling-handoff.ts`.
33
+ * `handleStart` consumes the record before any probe; an env port alone grants nothing.
34
+ *
35
+ * Deliberately import-free: the gate, the server and the CLI all read it, and a leaf cannot form a
36
+ * cycle.
37
+ */
38
+
39
+ let livePort: number | null = null;
40
+
41
+ /** Mark this process as a sibling of the proxy serving `port`. Idempotent; never unset. */
42
+ export function markSiblingStart(port: number): void {
43
+ livePort = port;
44
+ }
45
+
46
+ /** The live owner's port when this process is a sibling, otherwise `null`. */
47
+ export function siblingOfLivePort(): number | null {
48
+ return livePort;
49
+ }
50
+
51
+ /** Test seam only: production never clears the mark. */
52
+ export function resetSiblingStartForTests(): void {
53
+ livePort = null;
54
+ }
55
+
56
+ /**
57
+ * The one line a sibling prints wherever it declines a shared client write.
58
+ *
59
+ * With the sibling's own port it describes the split; without one (a skip deep inside a writer
60
+ * that does not know the port) it names what was left alone.
61
+ */
62
+ export function siblingSkipMessage(ownPort?: number): string {
63
+ const live = livePort ?? "unknown";
64
+ return ownPort === undefined
65
+ ? `Client routing stays on the proxy at port ${live}; this instance does not rewrite Codex, Grok or Claude configs.`
66
+ : `Client routing stays on the proxy at port ${live}; this instance serves direct requests on port ${ownPort} only.`;
67
+ }
68
+
69
+ /**
70
+ * The optional runtime-record field that tells a later `ocx stop` it is stopping a sibling.
71
+ * Empty when unmarked, so a non-sibling record stays byte-identical.
72
+ */
73
+ export function siblingRuntimeField(): { siblingOfPort?: number } {
74
+ return livePort === null ? {} : { siblingOfPort: livePort };
75
+ }
76
+
77
+ /** The env var a sibling hands its own replacement `ocx start`: the live owner's port. */
78
+ export const SIBLING_OF_PORT_ENV = "OCX_SIBLING_OF_PORT";
79
+ export const SIBLING_HANDOFF_NONCE_ENV = "OCX_SIBLING_HANDOFF_NONCE";
80
+
81
+ type Env = Record<string, string | undefined>;
82
+
83
+ /** The owner port an inherited marker names, or `null` when absent or not a TCP port. */
84
+ export function parseSiblingMarker(raw: string | undefined): number | null {
85
+ const trimmed = raw?.trim() ?? "";
86
+ if (!/^[0-9]{1,5}$/.test(trimmed)) return null;
87
+ const port = Number(trimmed);
88
+ return port >= 1 && port <= 65535 ? port : null;
89
+ }
90
+
91
+ /**
92
+ * Honor a port only when its one-use handoff record was consumed. Both env fields are stripped
93
+ * before any probe, including on refusal, so a later detached child cannot inherit them.
94
+ */
95
+ export function honorSiblingMarker(env: Env, consumeHandoff: (port: number, nonce: string | undefined) => boolean): number | null {
96
+ const port = parseSiblingMarker(env[SIBLING_OF_PORT_ENV]);
97
+ const nonce = env[SIBLING_HANDOFF_NONCE_ENV];
98
+ delete env[SIBLING_OF_PORT_ENV];
99
+ delete env[SIBLING_HANDOFF_NONCE_ENV];
100
+ if (port === null || !consumeHandoff(port, nonce)) return null;
101
+ markSiblingStart(port);
102
+ return port;
103
+ }
104
+
105
+ /** A copy of `env` for this process's own replacement, with a one-use handoff when marked. */
106
+ export function withSiblingMarker<T extends Env>(env: T, issueHandoff?: (port: number) => string): T {
107
+ const next: Env = withoutSiblingMarker(env);
108
+ if (livePort !== null) {
109
+ if (!issueHandoff) throw new Error("Sibling replacement requires a one-use handoff issuer.");
110
+ next[SIBLING_OF_PORT_ENV] = String(livePort);
111
+ next[SIBLING_HANDOFF_NONCE_ENV] = issueHandoff(livePort);
112
+ }
113
+ return next as T;
114
+ }
115
+
116
+ /** A copy of `env` for a child that must start as an ordinary owner (`ocx ensure`, the tray). */
117
+ export function withoutSiblingMarker<T extends Env>(env: T): T {
118
+ const next: Env = { ...env };
119
+ delete next[SIBLING_OF_PORT_ENV];
120
+ delete next[SIBLING_HANDOFF_NONCE_ENV];
121
+ return next as T;
122
+ }
123
+
124
+ /**
125
+ * Whether `ocx stop` of a sibling runtime found the live owner instead of the sibling.
126
+ *
127
+ * A hard-killed sibling leaves its record behind with a dead pid, so the stop falls through to
128
+ * discovery, and discovery ends on the configured port, where the live owner answers. Stopping that
129
+ * proxy is the outage the sibling design exists to prevent. The found proxy is the owner when it
130
+ * answers on `siblingOfPort`, or when only the configured-port fallback reached it (the sibling's
131
+ * own record did not answer). `live` is `findLiveProxy`'s result.
132
+ */
133
+ export function siblingStopFoundOwner(
134
+ siblingOfPort: number | undefined,
135
+ live: { port: number; source: "runtime" | "config" } | null,
136
+ ): boolean {
137
+ if (siblingOfPort === undefined || live === null) return false;
138
+ return live.port === siblingOfPort || live.source === "config";
139
+ }
package/src/codex/sync.ts CHANGED
@@ -284,9 +284,11 @@ export async function syncModelsToCodex(
284
284
  if (result.status === "skipped") {
285
285
  return {
286
286
  status: "skipped",
287
- // The apply direction's only under-lock policy skips are desired OFF and the hub gate;
288
- // carry whichever the injector reported so the caller can say the honest thing.
289
- skippedReason: result.skippedReason === "hub-gated" ? "hub-gated" : "desired_disabled",
287
+ // The apply direction's only policy skips are desired OFF, the hub gate and a sibling
288
+ // instance; carry whichever the injector reported so the caller can say the honest thing.
289
+ skippedReason: result.skippedReason === "hub-gated" || result.skippedReason === "sibling"
290
+ ? result.skippedReason
291
+ : "desired_disabled",
290
292
  ok: true,
291
293
  added: 0,
292
294
  catalogPath: null,
@@ -230,11 +230,17 @@ export function coolComboTarget(
230
230
  const cooldownMs = serverDelayMs
231
231
  ?? parseResetCooldownMs(options?.resetAt, now)
232
232
  ?? options?.cooldownMs
233
- ?? (isTransientRequestRateLimit({
234
- status: options?.status,
235
- code: options?.code,
236
- message: options?.message,
237
- }) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
233
+ // A spent account window or an unpaid/rejected credential does not turn over in a minute,
234
+ // so the 60s default would re-offer a target that cannot succeed. Only the duration
235
+ // changes; the scope and hop decisions are untouched.
236
+ ?? (isAccountWindowExhausted(options?.message ?? "", options?.code)
237
+ || PROVIDER_SCOPED_FAILURE_CODES.has(normalizedFailureCode(options?.code))
238
+ ? MAX_COOLDOWN_MS
239
+ : isTransientRequestRateLimit({
240
+ status: options?.status,
241
+ code: options?.code,
242
+ message: options?.message,
243
+ }) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
238
244
  targetCooldowns.set(cooldownMapKey(comboId, target), {
239
245
  // Local fallbacks are capped at ten minutes; explicit server delays at one day.
240
246
  cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)),
@@ -306,6 +312,27 @@ function normalizedFailureCode(code?: string | null): string {
306
312
  return code?.trim().toLowerCase().replaceAll("-", "_") ?? "";
307
313
  }
308
314
 
315
+ /**
316
+ * A spent account window, by structured code or upstream prose. Status is deliberately not
317
+ * consulted: the ChatGPT Codex backend reports a depleted plan window as HTTP 502
318
+ * `upstream_server_error` carrying `The usage limit has been reached`, never the documented 429,
319
+ * so any status gate misses it. Read in exactly ONE place -- the cooldown DURATION fallback. A
320
+ * status-blind prose match is safe for choosing how long to wait; it is not safe for choosing
321
+ * what to black out, so `isProviderScopedQuotaCap` and the scope/decision paths stay untouched
322
+ * and a Codex 502 still resolves `target` scope and `hop` through `status >= 500`.
323
+ */
324
+ // `1308` is the vendor code for a spent five-hour window and carries no prose of its own when the
325
+ // upstream reports it bare, so it belongs here too. The rest of QUOTA_LIMIT_CODES stays out: those
326
+ // are quota-limit codes whose window length this gateway has no evidence for, and guessing long on
327
+ // them would hold a target that may clear sooner.
328
+ const ACCOUNT_EXHAUSTION_CODES = new Set(["usage_limit_exceeded", "usage_limit_reached", "1308"]);
329
+ const ACCOUNT_EXHAUSTION_TEXT = /usage limit (?:has been )?reached/;
330
+
331
+ function isAccountWindowExhausted(message: string, code?: string | null): boolean {
332
+ return ACCOUNT_EXHAUSTION_CODES.has(normalizedFailureCode(code))
333
+ || ACCOUNT_EXHAUSTION_TEXT.test(message.toLowerCase());
334
+ }
335
+
309
336
  function isProviderScopedQuotaCap(
310
337
  status: number | undefined,
311
338
  message: string,
@@ -1,5 +1,5 @@
1
1
  import type { OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboTarget, OcxConfig } from "../types";
2
- import { isCodexReasoningEffort, resolveEffortAtOrBelow } from "../reasoning-effort";
2
+ import { isCodexReasoningEffort, isDeclaredReasoningEffort, resolveEffortAtOrBelow } from "../reasoning-effort";
3
3
  import { resolveComboId } from "./types";
4
4
 
5
5
  const warnedUnsupportedDefaults = new Set<string>();
@@ -54,6 +54,11 @@ function responsesInputNodeHasImage(value: unknown): boolean {
54
54
  return false;
55
55
  }
56
56
 
57
+ /**
58
+ * Clone a logical combo request for one concrete target and apply the combo's reasoning policy.
59
+ * Force mode overrides every valid declared caller effort only when the target ladder proves the
60
+ * configured default is representable; unknown capabilities stay conservative.
61
+ */
57
62
  export function concreteComboRequestBody(
58
63
  body: unknown,
59
64
  target: Pick<OcxComboTarget, "provider" | "model">,
@@ -79,9 +84,12 @@ export function concreteComboRequestBody(
79
84
  const hasEffort = reasoningRecord !== undefined
80
85
  && Object.prototype.hasOwnProperty.call(reasoningRecord, "effort");
81
86
  const callerEffort = reasoningRecord?.effort;
82
- const validCallerEffort = typeof callerEffort === "string" && isCodexReasoningEffort(callerEffort);
87
+ const declaredCallerEffort = typeof callerEffort === "string" && isDeclaredReasoningEffort(callerEffort);
83
88
  const needsDefault = reasoning === undefined || (reasoningRecord !== undefined && !hasEffort);
84
- const shouldForce = defaultEffortMode === "force" && validCallerEffort;
89
+ // `none` and `minimal` are valid request sentinels even though they are outside the ranked
90
+ // low..ultra Codex ladder. Force mode means the combo policy wins over any valid declared
91
+ // caller effort, including those sentinels.
92
+ const shouldForce = defaultEffortMode === "force" && declaredCallerEffort;
85
93
  if (!needsDefault && !shouldForce) return clone;
86
94
  // Picker availability treats an unknown ladder as a wildcard, but runtime
87
95
  // injection stays fail-closed until this concrete target advertises support.