@bitkyc08/opencodex 2.81.0 → 2.83.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 (272) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ba8uG696.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/cursor/catalog.ts +2 -2
  11. package/src/adapters/cursor/effort-map.ts +1 -1
  12. package/src/adapters/devin/live-models.ts +2 -2
  13. package/src/adapters/google-truncation.ts +22 -0
  14. package/src/adapters/google.ts +7 -3
  15. package/src/adapters/ollama-native.ts +18 -14
  16. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  17. package/src/adapters/openai-responses/passthrough.ts +11 -4
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  21. package/src/bridge/errors.ts +7 -1
  22. package/src/claude/agents-inject.ts +14 -3
  23. package/src/claude/context-windows.ts +22 -20
  24. package/src/claude/desktop-3p.ts +88 -12
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/desktop-profile.ts +2 -1
  27. package/src/claude/gateway-cache.ts +18 -1
  28. package/src/claude/inbound.ts +77 -3
  29. package/src/claude/intercept/cli-catalog.ts +3 -0
  30. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  31. package/src/claude/intercept/picker-models.ts +14 -6
  32. package/src/claude/intercept/settings.ts +3 -0
  33. package/src/claude/long-context.ts +66 -0
  34. package/src/claude/model-info.ts +19 -16
  35. package/src/claude/outbound.ts +94 -9
  36. package/src/claude/subagent-model.ts +40 -11
  37. package/src/cli/account-auth.ts +20 -12
  38. package/src/cli/agent.ts +9 -5
  39. package/src/cli/aside-profile-recovery.ts +33 -0
  40. package/src/cli/capabilities-base.ts +5 -5
  41. package/src/cli/capabilities-integrations.ts +2 -2
  42. package/src/cli/cli-path-diagnostics.ts +132 -0
  43. package/src/cli/desktop-runtime-guidance.ts +65 -0
  44. package/src/cli/dispatch.ts +5 -0
  45. package/src/cli/doctor.ts +16 -3
  46. package/src/cli/index.ts +14 -9
  47. package/src/cli/integration-aside-sync.ts +2 -1
  48. package/src/cli/integrations.ts +16 -3
  49. package/src/cli/resolve.ts +26 -3
  50. package/src/cli/root.ts +4 -2
  51. package/src/cli/status.ts +64 -12
  52. package/src/cli/update-restart-child.ts +4 -2
  53. package/src/cli/update-restart-home.ts +28 -10
  54. package/src/cli/update-restart-service-record.ts +116 -0
  55. package/src/cli/update-restart-supervision.ts +96 -0
  56. package/src/cli/update-restart.ts +59 -13
  57. package/src/cli/v2.ts +27 -20
  58. package/src/cli/version-skew-notice.ts +77 -0
  59. package/src/clients/config-export/model-metadata.ts +16 -0
  60. package/src/clients/config-export/zcode-store.ts +46 -11
  61. package/src/clients/config-export/zcode.ts +3 -5
  62. package/src/clients/config-export.ts +1 -1
  63. package/src/clients/lazycodex.ts +1 -1
  64. package/src/clients/omo-role-models.ts +32 -7
  65. package/src/codex/auth-api/account-selection.ts +107 -0
  66. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  67. package/src/codex/auth-api/routes.ts +10 -49
  68. package/src/codex/auth-context.ts +28 -4
  69. package/src/codex/autostart-health.ts +12 -3
  70. package/src/codex/catalog/build-entries.ts +18 -0
  71. package/src/codex/catalog/bundled.ts +155 -26
  72. package/src/codex/catalog/combo-member.ts +3 -2
  73. package/src/codex/catalog/effort.ts +26 -5
  74. package/src/codex/catalog/gather-capture.ts +5 -0
  75. package/src/codex/catalog/model-hints.ts +15 -8
  76. package/src/codex/catalog/model-visibility.ts +3 -1
  77. package/src/codex/catalog/native-models.ts +1 -1
  78. package/src/codex/catalog/operator-tiers.ts +38 -0
  79. package/src/codex/catalog/provider-models.ts +68 -35
  80. package/src/codex/catalog/retained-sync.ts +7 -5
  81. package/src/codex/catalog/routed-gather.ts +15 -1
  82. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  83. package/src/codex/catalog-auto-refresh.ts +19 -0
  84. package/src/codex/catalog.ts +1 -1
  85. package/src/codex/codex-home-owner.ts +3 -0
  86. package/src/codex/config-write-lock.ts +333 -0
  87. package/src/codex/convergence.ts +2 -1
  88. package/src/codex/desktop-app/darwin.ts +1 -1
  89. package/src/codex/desktop-app/handoff.ts +1 -1
  90. package/src/codex/desktop-app/linux.ts +1 -1
  91. package/src/codex/desktop-app/lock.ts +1 -1
  92. package/src/codex/desktop-app/types.ts +1 -1
  93. package/src/codex/desktop-app/windows.ts +1 -1
  94. package/src/codex/desktop-app-restart.ts +1 -1
  95. package/src/codex/features.ts +257 -200
  96. package/src/codex/inject/config-write-section.ts +34 -0
  97. package/src/codex/inject/multi-agent-v2.ts +14 -6
  98. package/src/codex/inject/remove.ts +36 -11
  99. package/src/codex/inject/restore.ts +85 -20
  100. package/src/codex/inject-coordination.ts +15 -6
  101. package/src/codex/inject.ts +62 -20
  102. package/src/codex/journal.ts +62 -17
  103. package/src/codex/main-account-cache.ts +43 -0
  104. package/src/codex/main-account.ts +6 -7
  105. package/src/codex/paths.ts +2 -0
  106. package/src/codex/prepared-home.ts +18 -0
  107. package/src/codex/prompt-journal.ts +25 -12
  108. package/src/codex/prompt-layers.ts +49 -26
  109. package/src/codex/prompt-lock-claim.ts +134 -0
  110. package/src/codex/prompt-lock-evidence.ts +26 -0
  111. package/src/codex/prompt-lock-io.ts +16 -0
  112. package/src/codex/prompt-lock-owner.ts +130 -0
  113. package/src/codex/prompt-lock.ts +84 -53
  114. package/src/codex/routing/failure-window.ts +146 -0
  115. package/src/codex/routing/selection.ts +4 -1
  116. package/src/codex/routing.ts +13 -17
  117. package/src/codex/runtime.ts +106 -18
  118. package/src/codex/shim.ts +45 -19
  119. package/src/combos/jev-service-exchange.ts +281 -0
  120. package/src/combos/jev.ts +34 -220
  121. package/src/config/atomic-write.ts +1 -1
  122. package/src/config/codex-ws-reuse-setting.ts +10 -0
  123. package/src/config/live-reconcile.ts +17 -0
  124. package/src/config/load-degrade.ts +8 -0
  125. package/src/config/rebase-provenance.ts +11 -0
  126. package/src/config/schema/config-schema.ts +4 -0
  127. package/src/generated/compatibility-version.json +375 -227
  128. package/src/generated/model-metadata.ts +2 -2
  129. package/src/grok/inject.ts +276 -206
  130. package/src/grok/reset-coupon-ledger.ts +223 -57
  131. package/src/grok/reset-coupons.ts +13 -6
  132. package/src/grok/sync.ts +2 -1
  133. package/src/images/loop.ts +29 -11
  134. package/src/integrations/aside-profiles.ts +10 -3
  135. package/src/integrations/catalog-refresh.ts +18 -2
  136. package/src/integrations/cursor-detect.ts +1 -1
  137. package/src/integrations/writer.ts +9 -3
  138. package/src/lib/bun-path-runtime.d.mts +32 -0
  139. package/src/lib/bun-path-runtime.mjs +95 -0
  140. package/src/lib/bun-runtime-preflight.ts +140 -0
  141. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  142. package/src/lib/desktop-cli-handoff.mjs +92 -0
  143. package/src/lib/desktop-cli-record.d.mts +29 -0
  144. package/src/lib/desktop-cli-record.mjs +102 -0
  145. package/src/lib/errors.ts +25 -0
  146. package/src/lib/redact.ts +2 -2
  147. package/src/lib/request-execution-budget.ts +8 -0
  148. package/src/lib/synced-state-location.ts +46 -6
  149. package/src/lib/test-home-guard.ts +87 -1
  150. package/src/lib/translator-budget.ts +17 -4
  151. package/src/lib/windows-atomic-replace.ts +38 -16
  152. package/src/lib/windows-elevation.ts +6 -0
  153. package/src/lib/windows-secret-acl.ts +92 -25
  154. package/src/lib/winsw.ts +2 -3
  155. package/src/oauth/anthropic-account-refusal.ts +39 -1
  156. package/src/oauth/anthropic-routing.ts +1 -1
  157. package/src/oauth/anthropic-send-ownership.ts +3 -3
  158. package/src/oauth/chatgpt.ts +113 -41
  159. package/src/oauth/index.ts +7 -3
  160. package/src/oauth/meta-muse-device.ts +1 -1
  161. package/src/oauth/store.ts +1 -1
  162. package/src/oauth/types.ts +1 -1
  163. package/src/providers/anthropic-fast.ts +1 -1
  164. package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
  165. package/src/providers/azure-model-metadata.ts +115 -0
  166. package/src/providers/derive.ts +21 -0
  167. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  168. package/src/providers/devin-provider-merge-migration.ts +1 -1
  169. package/src/providers/github-copilot-auto.ts +169 -0
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/muse-subscription-usage.ts +1 -1
  172. package/src/providers/quota/vendor-probes-key.ts +127 -17
  173. package/src/providers/quota.ts +1 -1
  174. package/src/providers/registry/entries-core.ts +6 -6
  175. package/src/providers/registry/entries-extended.ts +4 -0
  176. package/src/providers/registry/model-seeds.ts +3 -3
  177. package/src/providers/xai-fast-model.ts +1 -1
  178. package/src/responses/reasoning-envelope.ts +29 -1
  179. package/src/responses/reasoning-replay-cache.ts +17 -1
  180. package/src/responses/spill-store.ts +15 -2
  181. package/src/responses/state/metrics.ts +8 -0
  182. package/src/responses/state/spill-failure.ts +12 -0
  183. package/src/responses/state/spill-queue.ts +12 -5
  184. package/src/responses/state.ts +18 -10
  185. package/src/responses/task-input.ts +17 -0
  186. package/src/server/background-lifecycle.ts +27 -0
  187. package/src/server/chat-completions.ts +1 -0
  188. package/src/server/chat-native.ts +28 -12
  189. package/src/server/claude-messages.ts +43 -6
  190. package/src/server/inbound-body-admission.ts +25 -7
  191. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  192. package/src/server/index/link-listener.ts +30 -17
  193. package/src/server/index/serve-options.ts +18 -4
  194. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  195. package/src/server/index/startup-warnings.ts +19 -0
  196. package/src/server/index.ts +2 -1
  197. package/src/server/lifecycle.ts +31 -4
  198. package/src/server/management/agent-settings-routes.ts +93 -32
  199. package/src/server/management/codex-agent-role-routes.ts +4 -2
  200. package/src/server/management/codex-prompt-routes.ts +1 -0
  201. package/src/server/management/codex-role-auto-assign.ts +13 -2
  202. package/src/server/management/config-routes.ts +59 -8
  203. package/src/server/management/context.ts +2 -2
  204. package/src/server/management/grok-coupon-routes.ts +277 -52
  205. package/src/server/management/link-routes.ts +42 -20
  206. package/src/server/management/route-registry.ts +2 -2
  207. package/src/server/management/shared.ts +3 -3
  208. package/src/server/messages-native-oauth.ts +9 -8
  209. package/src/server/messages-native.ts +49 -13
  210. package/src/server/messages-response-headers.ts +14 -0
  211. package/src/server/relay-eager.ts +2 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-conversation.ts +48 -5
  214. package/src/server/request-log-failure-attribution.ts +3 -0
  215. package/src/server/request-log-terminal-status.ts +186 -0
  216. package/src/server/request-log.ts +15 -89
  217. package/src/server/responses/adapter-continuation.ts +11 -3
  218. package/src/server/responses/adapter-dispatch.ts +99 -19
  219. package/src/server/responses/codex-ws-exchange.ts +17 -2
  220. package/src/server/responses/codex-ws-pool.ts +100 -13
  221. package/src/server/responses/codex-ws-wire.ts +9 -0
  222. package/src/server/responses/combo-requested-effort.ts +3 -1
  223. package/src/server/responses/compact.ts +3 -2
  224. package/src/server/responses/core-codex-account.ts +22 -1
  225. package/src/server/responses/core-combo-native.ts +20 -3
  226. package/src/server/responses/core-combo.ts +22 -14
  227. package/src/server/responses/core-lifetime.ts +3 -36
  228. package/src/server/responses/core-opaque-recovery.ts +5 -5
  229. package/src/server/responses/core-options.ts +5 -0
  230. package/src/server/responses/core-replay.ts +73 -0
  231. package/src/server/responses/core.ts +9 -9
  232. package/src/server/responses/empty-completion-guard.ts +18 -2
  233. package/src/server/responses/fetch-helpers.ts +26 -3
  234. package/src/server/responses/jev-model-invoke.ts +1 -1
  235. package/src/server/responses/passthrough-delivery.ts +24 -2
  236. package/src/server/responses/passthrough-dispatch.ts +134 -62
  237. package/src/server/responses/passthrough-execution.ts +3 -3
  238. package/src/server/responses/request-prepare.ts +4 -0
  239. package/src/server/responses/request-send-budget.ts +112 -49
  240. package/src/server/responses/request-transport.ts +125 -5
  241. package/src/server/responses/response-effects.ts +1 -0
  242. package/src/server/responses/run-turn-execution.ts +15 -5
  243. package/src/server/responses/sidecar-execution.ts +30 -16
  244. package/src/server/responses/sidecar-send-budget.ts +58 -0
  245. package/src/server/responses/spend-storage-error.ts +33 -0
  246. package/src/server/responses/ws-upstream.ts +19 -6
  247. package/src/server/startup-health-cache.ts +2 -1
  248. package/src/server/workflow-refusal.ts +3 -0
  249. package/src/service/cli.ts +20 -4
  250. package/src/service/desktop-command-guard.ts +32 -0
  251. package/src/service/desktop-startup.ts +66 -40
  252. package/src/service/desktop-supervision.d.mts +26 -0
  253. package/src/service/desktop-supervision.mjs +140 -0
  254. package/src/service/managing-cli.ts +1 -0
  255. package/src/service/orchestration.ts +46 -20
  256. package/src/service/repair.ts +25 -9
  257. package/src/service/state.ts +4 -3
  258. package/src/service/systemd.ts +17 -8
  259. package/src/service/windows-ops.ts +61 -22
  260. package/src/storage/policy-job.ts +5 -1
  261. package/src/storage/policy-worker.ts +3 -1
  262. package/src/storage/policy.ts +9 -0
  263. package/src/types/config.ts +21 -0
  264. package/src/types/request.ts +10 -0
  265. package/src/types/wire.ts +1 -0
  266. package/src/update/index.ts +57 -9
  267. package/src/update/restart-ownership.ts +10 -3
  268. package/src/update/runtime-ownership.d.mts +6 -0
  269. package/src/update/runtime-ownership.mjs +23 -7
  270. package/src/usage/expected-prices.ts +1 -1
  271. package/src/web-search/loop.ts +5 -2
  272. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -19,6 +19,7 @@ import { codexPlanKey } from "../plan";
19
19
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
20
20
  import type { OcxConfig } from "../../types";
21
21
  import { CODEX_FAILURE_WINDOW_MS, computeCodexUsageScore } from "./cooldown-math";
22
+ import { failureWindowSteersNewThreads } from "./failure-window";
22
23
  import {
23
24
  codexPoolKeyForScope,
24
25
  dropSpentCredentialFailure,
@@ -211,6 +212,7 @@ export function getEligiblePoolAccounts(
211
212
  && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
212
213
  .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
213
214
  .filter(account => !isCodexAccountSoftAvoided(account.id, now))
215
+ .filter(account => !failureWindowSteersNewThreads(config, account.id, now))
214
216
  .filter(account => !isCodexQuotaAvoided(account.id, quotaScope, now))
215
217
  .filter(account => !isCodexPoolRefreshCooling(account.id, now))
216
218
  .filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
@@ -222,6 +224,7 @@ export function getEligiblePoolAccounts(
222
224
  && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
223
225
  && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
224
226
  && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
227
+ && !failureWindowSteersNewThreads(config, MAIN_CODEX_ACCOUNT_ID, now)
225
228
  // The main login is not in `config.codexAccounts`, so it never passes through the
226
229
  // filters above and this is the only place an avoidance window can exclude it. Without
227
230
  // this the window a refusal announced applies to the pool but not to the account that
@@ -863,7 +866,7 @@ export function applyFailureFailover(
863
866
  selectionOptions?: CodexAccountUsabilityOptions,
864
867
  commitSharedSelection = true,
865
868
  ): string {
866
- if (!shouldFailover(config, active, now)) return active;
869
+ if (!shouldFailover(config, active, now) && !failureWindowSteersNewThreads(config, active, now)) return active;
867
870
  const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
868
871
  if (best) {
869
872
  // The scope still routes away from the failing account — that is this request's
@@ -19,6 +19,13 @@ import type { OcxConfig } from "../types";
19
19
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
20
20
  import { recordUpstreamHostFailure } from "./upstream-host-health";
21
21
  import type { CodexThreadLineage } from "./lineage";
22
+ import {
23
+ failureWindowSteersNewThreads,
24
+ forgetCodexFailureWindow,
25
+ forgetCodexFailureWindows,
26
+ noteCodexFailureWindowSample,
27
+ settleCodexSuccessStreak,
28
+ } from "./routing/failure-window";
22
29
 
23
30
  import { isCodexPoolRefreshCooling } from "./pool-refresh-backoff";
24
31
  import {
@@ -238,6 +245,7 @@ export function clearCodexUpstreamHealth(): void {
238
245
  clearAllManualPreferences();
239
246
  clearIdleWindowSteering();
240
247
  clearUpstreamHealthState();
248
+ forgetCodexFailureWindows();
241
249
  forgetRuntimeActiveCodexAccount();
242
250
  // The reconcile watermark is part of this state, not something that outlives it. Keeping
243
251
  // it across a full reset is incoherent: there is no health left to protect, yet
@@ -249,6 +257,7 @@ export function clearCodexUpstreamHealth(): void {
249
257
 
250
258
  export function clearCodexUpstreamHealthForAccount(accountId: string): void {
251
259
  deleteAllHealthForAccount(accountId);
260
+ forgetCodexFailureWindow(accountId);
252
261
  // Deletion is the third operator exit, next to pause and exclusion, and it is the one
253
262
  // with no reconcile path behind it: once the account is gone nothing can succeed on it,
254
263
  // so an unspent preference naming it would suppress the automatic cursor for every other
@@ -829,7 +838,7 @@ export function previewCodexAccountForRequest(
829
838
  active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
830
839
  }
831
840
  }
832
- if (shouldFailover(config, active, now)) {
841
+ if (shouldFailover(config, active, now) || failureWindowSteersNewThreads(config, active, now)) {
833
842
  const best = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
834
843
  if (best) active = best;
835
844
  }
@@ -1296,6 +1305,7 @@ export function recordCodexUpstreamOutcome(
1296
1305
  */
1297
1306
  dropSpentCredentialFailure(accountId);
1298
1307
  if (outcomeClass === "success") {
1308
+ noteCodexFailureWindowSample(config, accountId, false, now);
1299
1309
  // The operator's one-shot is spent by a dispatch that actually worked, and only by that.
1300
1310
  // A failed lookup leaves it unspent so the intent survives the failure.
1301
1311
  consumeManualPreference(accountId, codexPoolKeyForScope(quotaScope));
@@ -1336,22 +1346,7 @@ export function recordCodexUpstreamOutcome(
1336
1346
  // Non-owners keep every hard-cooldown field, including someone else's live lease.
1337
1347
  const base = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
1338
1348
  const preserved = preservedCooldownFields(base);
1339
- const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
1340
- if (failoverEnabled && current && current.consecutiveFailures >= 2) {
1341
- const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
1342
- if (consecutiveSuccesses < 2) {
1343
- setAccountHealth(accountId, {
1344
- ...base!,
1345
- ...preserved,
1346
- consecutiveSuccesses,
1347
- });
1348
- return;
1349
- }
1350
- }
1351
- // Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
1352
- // Hard quota cooldown intentionally survives either recovery path.
1353
- if (cooldownUntil) setAccountHealth(accountId, { consecutiveFailures: 0, ...preserved });
1354
- else deleteAccountHealth(accountId);
1349
+ settleCodexSuccessStreak(config, accountId, current, base, preserved, cooldownUntil);
1355
1350
  return;
1356
1351
  }
1357
1352
  if (outcomeClass === "caller") {
@@ -1560,6 +1555,7 @@ export function recordCodexUpstreamOutcome(
1560
1555
  // A transient failure concludes an owning probe; an unrelated 5xx must not
1561
1556
  // consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
1562
1557
  const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
1558
+ noteCodexFailureWindowSample(config, accountId, true, now);
1563
1559
  const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
1564
1560
  const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
1565
1561
  // Soft avoid + affinity clears are part of failover. When threshold is 0, leave
@@ -7,6 +7,7 @@ import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocatio
7
7
  import { resolveCodexHomeDir } from "./home";
8
8
  import { decodeOverlayState } from "./shim-state-file";
9
9
  import { redactSecretString, redactUserPath } from "../lib/redact";
10
+ import { SUBPROCESS_KILL_GRACE_MS, waitForSubprocessExit } from "../lib/bounded-subprocess";
10
11
 
11
12
  export type CodexRuntimeSource =
12
13
  | "environment"
@@ -68,6 +69,8 @@ export type RuntimeExecFile = (
68
69
  shell?: boolean;
69
70
  windowsVerbatimArguments?: boolean;
70
71
  env?: NodeJS.ProcessEnv;
72
+ signal?: AbortSignal;
73
+ maxBuffer?: number;
71
74
  },
72
75
  ) => string;
73
76
 
@@ -77,6 +80,7 @@ export type RuntimeExecFileAsync = (
77
80
  ) => Promise<string>;
78
81
 
79
82
  export interface ResolveCodexRuntimeDeps {
83
+ signal?: AbortSignal;
80
84
  env?: NodeJS.ProcessEnv;
81
85
  platform?: NodeJS.Platform;
82
86
  configDir?: string;
@@ -487,23 +491,62 @@ function probeVersion(command: string, deps: ResolveCodexRuntimeDeps): VersionPr
487
491
  }
488
492
  }
489
493
 
490
- const defaultExecFileAsync: RuntimeExecFileAsync = (file, args, options) =>
494
+ export const execCodexFileAsync: RuntimeExecFileAsync = (file, args, options) =>
491
495
  new Promise((resolve, reject) => {
496
+ if (options.signal?.aborted) { reject(new Error("Codex subprocess aborted")); return; }
492
497
  // execFile always pipes stdout/stderr; it takes no stdio option.
493
- const { stdio: _stdio, ...execOptions } = options;
494
- execFile(file, args, execOptions, (error, stdout) => (error ? reject(error) : resolve(String(stdout))));
498
+ // Own abort/deadline termination; execFile's built-in observer can settle the
499
+ // callback while a SIGTERM-ignoring child is still alive.
500
+ const { stdio: _stdio, signal: _signal, timeout: _timeout, ...execOptions } = options;
501
+ let settled = false;
502
+ let timer: ReturnType<typeof setTimeout> | undefined;
503
+ let abort = () => {};
504
+ const finish = (error: Error | null, stdout = "") => {
505
+ if (settled) return;
506
+ settled = true;
507
+ clearTimeout(timer);
508
+ options.signal?.removeEventListener("abort", abort);
509
+ if (error) reject(error); else resolve(stdout);
510
+ };
511
+ const child = execFile(file, args, execOptions, (error, stdout) => {
512
+ if (error && child.exitCode === null && child.signalCode === null) stop(error);
513
+ else finish(error, String(stdout));
514
+ });
515
+ const exited = new Promise<number>(resolveExit => {
516
+ child.once("exit", code => resolveExit(code ?? 1));
517
+ // A kill error is not exit proof; only failed spawn has no child to reap.
518
+ child.once("error", () => { if (!child.pid) resolveExit(1); });
519
+ });
520
+ // A reaper owns actual exit independently of caller-facing promise settlement.
521
+ let stopping = false;
522
+ const stop = (error: Error) => {
523
+ if (stopping) return;
524
+ stopping = true;
525
+ try { child.kill(); } catch { /* exit/error observation still owns reaping */ }
526
+ child.stdout?.destroy();
527
+ child.stderr?.destroy();
528
+ void waitForSubprocessExit({ exited, kill: () => child.kill("SIGKILL") }, SUBPROCESS_KILL_GRACE_MS);
529
+ finish(error);
530
+ };
531
+ abort = () => stop(new Error("Codex subprocess aborted"));
532
+ options.signal?.addEventListener("abort", abort, { once: true });
533
+ if (options.timeout > 0) {
534
+ timer = setTimeout(() => stop(new Error("Codex subprocess deadline exceeded")), options.timeout);
535
+ timer.unref?.();
536
+ }
537
+ if (options.signal?.aborted) abort();
495
538
  });
496
539
 
497
540
  /** probeVersion with the same sandbox and outcomes, but the exec never holds the event loop. */
498
541
  async function probeVersionAsync(command: string, deps: ResolveCodexRuntimeDeps): Promise<VersionProbeResult> {
499
542
  const settled = versionProbePrecheck(command, deps);
500
543
  if (settled) return settled;
501
- const exec = deps.execFile ?? defaultExecFileAsync;
544
+ const exec = deps.execFile ?? execCodexFileAsync;
502
545
  let probeHome: string | undefined;
503
546
  try {
504
547
  probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-"));
505
548
  const { file, args, options } = versionProbeInvocation(command, deps, probeHome);
506
- return versionProbeOutput(await exec(file, args, options));
549
+ return versionProbeOutput(await exec(file, args, { ...options, signal: deps.signal }));
507
550
  } catch (error) {
508
551
  return versionProbeError(error, probeHome);
509
552
  } finally {
@@ -782,9 +825,7 @@ function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string {
782
825
  const configDir = deps.configDir ?? getConfigDir();
783
826
  const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding));
784
827
  try {
785
- const raw = parsePersistedCodexRuntime(read(codexRuntimeStatePath(configDir), "utf8"));
786
- if (!raw) return "";
787
- return `${raw.command}|${raw.selectedVersion ?? ""}|${raw.updatedAt ?? ""}`;
828
+ return read(codexRuntimeStatePath(configDir), "utf8");
788
829
  } catch {
789
830
  return "";
790
831
  }
@@ -793,7 +834,8 @@ function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string {
793
834
  function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
794
835
  // Only memoize uninjected process-env resolves (settings/status hot paths).
795
836
  if (
796
- deps.execFileSync
837
+ deps.signal
838
+ || deps.execFileSync
797
839
  || deps.execFile
798
840
  || deps.existsSync
799
841
  || deps.readFileSync
@@ -804,8 +846,14 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
804
846
  ) {
805
847
  return null;
806
848
  }
849
+ return codexRuntimeSelectionIdentity(deps);
850
+ }
851
+
852
+ /** Exec-free input identity, including pin changes made by another process. */
853
+ export function codexRuntimeSelectionIdentity(deps: ResolveCodexRuntimeDeps = {}): string {
807
854
  const env = deps.env ?? process.env;
808
855
  return JSON.stringify({
856
+ configDir: deps.configDir ?? getConfigDir(),
809
857
  cli: env.CODEX_CLI_PATH ?? "",
810
858
  path: env.PATH ?? "",
811
859
  platform: deps.platform ?? process.platform,
@@ -859,7 +907,12 @@ export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): Resolve
859
907
  return cloneAndDeepFreeze(result);
860
908
  }
861
909
 
862
- let asyncResolveInflight: { key: string; epoch: number; promise: Promise<ResolveCodexRuntimeResult> } | null = null;
910
+ interface AsyncRuntimeObservation {
911
+ readonly result: ResolveCodexRuntimeResult;
912
+ readonly inputs: string;
913
+ readonly epoch: number | null;
914
+ }
915
+ let asyncResolveInflight: { key: string; epoch: number; promise: Promise<AsyncRuntimeObservation> } | null = null;
863
916
 
864
917
  /**
865
918
  * resolveCodexRuntime for server request paths: same selection and process memo, but every
@@ -869,16 +922,29 @@ let asyncResolveInflight: { key: string; epoch: number; promise: Promise<Resolve
869
922
  export async function resolveCodexRuntimeAsync(
870
923
  deps: ResolveCodexRuntimeDeps = {},
871
924
  ): Promise<ResolveCodexRuntimeResult> {
925
+ return cloneAndDeepFreeze((await resolveCodexRuntimeObservationAsync(deps)).result);
926
+ }
927
+
928
+ async function resolveCodexRuntimeObservationAsync(deps: ResolveCodexRuntimeDeps): Promise<AsyncRuntimeObservation> {
929
+ const inputs = codexRuntimeSelectionIdentity(deps);
930
+ const observed = (result: ResolveCodexRuntimeResult, epoch: number | null): AsyncRuntimeObservation => ({ result, inputs, epoch });
872
931
  // A deferred selection never execs, so the sync path is already nonblocking.
873
- if (deps.probeVersion === false) return resolveCodexRuntime(deps);
874
- const cacheKey = resolveCacheKey(deps);
932
+ if (deps.probeVersion === false) return observed(resolveCodexRuntime(deps), resolveCacheEpoch);
933
+ const cacheKey = resolveCacheKey({ ...deps, signal: undefined });
875
934
  if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) {
876
- return cloneAndDeepFreeze(resolveCache.value);
935
+ return observed(cloneAndDeepFreeze(resolveCache.value), resolveCacheEpoch);
877
936
  }
878
- if (!cacheKey) return cloneAndDeepFreeze(await resolveCodexRuntimeUncachedAsync(deps));
879
937
  const startedEpoch = resolveCacheEpoch;
938
+ // A cancellable miss owns its probes; a warm memo remains usable without a new child.
939
+ if (!cacheKey || deps.signal) {
940
+ const result = await resolveCodexRuntimeUncachedAsync(deps);
941
+ const current = !deps.signal?.aborted && resolveCacheEpoch === startedEpoch
942
+ && codexRuntimeSelectionIdentity(deps) === inputs;
943
+ if (current && cacheKey) publishResolveCache(cacheKey, Date.now(), result);
944
+ return observed(result, current ? resolveCacheEpoch : null);
945
+ }
880
946
  if (asyncResolveInflight?.key === cacheKey && asyncResolveInflight.epoch === startedEpoch) {
881
- return cloneAndDeepFreeze(await asyncResolveInflight.promise);
947
+ return asyncResolveInflight.promise;
882
948
  }
883
949
  const promise = resolveCodexRuntimeUncachedAsync(deps).then(result => {
884
950
  // A persist or clear while the probes ran makes this answer the previous selection's;
@@ -886,13 +952,14 @@ export async function resolveCodexRuntimeAsync(
886
952
  // rewrite codex-runtime.json without touching this epoch, so the key is read again too.
887
953
  if (resolveCacheEpoch === startedEpoch && resolveCacheKey(deps) === cacheKey) {
888
954
  publishResolveCache(cacheKey, Date.now(), result);
955
+ return observed(result, resolveCacheEpoch);
889
956
  }
890
- return result;
957
+ return observed(result, null);
891
958
  }).finally(() => {
892
959
  if (asyncResolveInflight?.promise === promise) asyncResolveInflight = null;
893
960
  });
894
961
  asyncResolveInflight = { key: cacheKey, epoch: startedEpoch, promise };
895
- return cloneAndDeepFreeze(await promise);
962
+ return promise;
896
963
  }
897
964
 
898
965
  /**
@@ -1087,7 +1154,10 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
1087
1154
  async function resolveCodexRuntimeUncachedAsync(deps: ResolveCodexRuntimeDeps): Promise<ResolveCodexRuntimeResult> {
1088
1155
  const steps = resolveCodexRuntimeSteps(deps);
1089
1156
  let step = steps.next();
1090
- while (!step.done) step = steps.next(await probeVersionAsync(step.value.command, deps));
1157
+ while (!step.done) {
1158
+ if (deps.signal?.aborted) throw new Error("Codex runtime resolution aborted");
1159
+ step = steps.next(await probeVersionAsync(step.value.command, deps));
1160
+ }
1091
1161
  return step.value;
1092
1162
  }
1093
1163
 
@@ -1096,6 +1166,24 @@ export function resolveAndPersistCodexRuntime(
1096
1166
  deps: ResolveCodexRuntimeDeps = {},
1097
1167
  ): ResolveCodexRuntimeResult {
1098
1168
  const result = resolveCodexRuntime(deps);
1169
+ return persistResolvedCodexRuntime(result, deps);
1170
+ }
1171
+
1172
+ /** Same selection/persistence as the CLI driver, with stale async results rejected. */
1173
+ export async function resolveAndPersistCodexRuntimeAsync(
1174
+ deps: ResolveCodexRuntimeDeps = {},
1175
+ isCurrent: () => boolean = () => true,
1176
+ ): Promise<ResolveCodexRuntimeResult | null> {
1177
+ const observation = await resolveCodexRuntimeObservationAsync(deps);
1178
+ if (!isCurrent() || observation.epoch === null || observation.epoch !== resolveCacheEpoch
1179
+ || observation.inputs !== codexRuntimeSelectionIdentity(deps)) return null;
1180
+ return cloneAndDeepFreeze(persistResolvedCodexRuntime(observation.result, deps));
1181
+ }
1182
+
1183
+ function persistResolvedCodexRuntime(
1184
+ result: ResolveCodexRuntimeResult,
1185
+ deps: ResolveCodexRuntimeDeps,
1186
+ ): ResolveCodexRuntimeResult {
1099
1187
  // Only WRITE when the selection actually changed. persistCodexRuntime() clears the
1100
1188
  // in-process resolve memo and persistedRuntimeCacheStamp() folds `updatedAt` into the
1101
1189
  // cache key, so an unconditional rewrite made every caller re-run the ~1s
package/src/codex/shim.ts CHANGED
@@ -9,7 +9,9 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
  import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path";
12
- import { durableBunRuntime } from "../lib/bun-runtime";
12
+ import { durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime";
13
+ import { assertSelectedRuntimeWritable, RuntimePreflightError, type RuntimePreflightDeps } from "../lib/bun-runtime-preflight";
14
+ import { getConfigDir } from "../config";
13
15
  import type { BunRuntimeSource } from "../lib/bun-runtime";
14
16
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
15
17
  import { isWslRuntime, wslAutomountRoot } from "./home";
@@ -83,7 +85,9 @@ export function lastCodexDiscoveryError(): string | null {
83
85
  return lastShimDiscoveryError;
84
86
  }
85
87
 
86
- interface InstallCodexShimInternalOptions {
88
+ interface InstallCodexShimInternalOptions extends RuntimePreflightDeps {
89
+ runtime?: DurableBunRuntime;
90
+ runtimeAdmitted?: boolean;
87
91
  expectedReplacements?: ReadonlyMap<string, ShimPathFingerprint>;
88
92
  allowFreshInstall: boolean;
89
93
  beforeGuardedRefresh?: (wrapperPath: string, index: number) => void;
@@ -105,12 +109,11 @@ export type CodexShimAutoRestoreResult =
105
109
  | { status: "ineligible" | "deferred"; message?: string }
106
110
  | { status: "restored"; message: string };
107
111
 
108
- function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
112
+ function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
109
113
  // Bundled Bun path (survives `ocx update`); all three shim builders
110
114
  // (Unix / Windows cmd / Windows PowerShell) receive it via this entry.
111
115
  // This module lives in src/codex/, the CLI entry in src/cli/index.ts.
112
116
  // Path and provenance resolve together so the marker always describes this binary.
113
- const runtime = durableBunRuntime();
114
117
  return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "..", "cli", "index.ts") };
115
118
  }
116
119
 
@@ -345,8 +348,8 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry
345
348
  * updater's wrapper can carry them, and a replacement landing between the write
346
349
  * and the observation is otherwise indistinguishable from our own file.
347
350
  */
348
- function writeShim(wrapperPath: string, realCodexPath: string): { dev: number; ino: number } | undefined {
349
- const { bun, bunRuntimeSource, cli } = cliEntry();
351
+ function writeShim(wrapperPath: string, realCodexPath: string, runtime?: DurableBunRuntime): { dev: number; ino: number } | undefined {
352
+ const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
350
353
  if (process.platform === "win32") {
351
354
  const lower = wrapperPath.toLowerCase();
352
355
  if (lower.endsWith(".ps1")) {
@@ -458,7 +461,7 @@ function replaceOwnedBackup(sourcePath: string, backupPath: string): void {
458
461
  }
459
462
  }
460
463
 
461
- function refreshShimFile(file: ShimFileState): boolean {
464
+ function refreshShimFile(file: ShimFileState, runtime: DurableBunRuntime): boolean {
462
465
  if (file.preserveOnly) {
463
466
  if (existsSync(file.originalPath) && !isShim(file.originalPath)) {
464
467
  replaceOwnedBackup(file.originalPath, file.backupPath);
@@ -474,10 +477,10 @@ function refreshShimFile(file: ShimFileState): boolean {
474
477
  file,
475
478
  expectedReplacement: replacement.fingerprint,
476
479
  sourcePath: file.wrapperPath,
477
- }]);
480
+ }], undefined, undefined, runtime);
478
481
  }
479
482
  if (!existsSync(file.wrapperPath) && existsSync(file.backupPath)) {
480
- writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
483
+ writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime);
481
484
  const writtenWrapper = stableShimPathProbe(file.wrapperPath);
482
485
  if (!writtenWrapper || !writtenWrapper.prefix.includes(SHIM_MARKER)) {
483
486
  return false;
@@ -503,7 +506,7 @@ function refreshShimFile(file: ShimFileState): boolean {
503
506
  }
504
507
  if (file.originalPath !== file.wrapperPath && existsSync(file.originalPath) && existsSync(file.wrapperPath) && isShim(file.wrapperPath)) {
505
508
  replaceOwnedBackup(file.originalPath, file.backupPath);
506
- writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
509
+ writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime);
507
510
  return true;
508
511
  }
509
512
  return false;
@@ -610,6 +613,7 @@ function applyGuardedRefreshTransaction(
610
613
  operations: readonly GuardedRefreshOperation[],
611
614
  beforeGuardedRefresh?: (wrapperPath: string, index: number) => void,
612
615
  commitState?: () => void,
616
+ runtime?: DurableBunRuntime,
613
617
  ): boolean {
614
618
  const journal: GuardedRefreshJournalEntry[] = [];
615
619
  let applyError: Error | null = null;
@@ -643,7 +647,7 @@ function applyGuardedRefreshTransaction(
643
647
  if (!movedReplacement) throw new Error("Codex shim guarded refresh could not fingerprint the staged launcher");
644
648
  entry.movedReplacementFingerprint = movedReplacement.fingerprint;
645
649
  entry.wrapperWriteStarted = true;
646
- const writtenInode = writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath);
650
+ const writtenInode = writeShim(operation.file.wrapperPath, operation.file.realPath ?? operation.file.backupPath, runtime);
647
651
  // Claim our own partial write before the hook can fail (see fresh install).
648
652
  entry.writtenWrapperFingerprint = ownedWrapperFingerprint(operation.file.wrapperPath, writtenInode);
649
653
  codexShimGuardedWriteHookForTests?.();
@@ -747,7 +751,7 @@ type ObsoleteUnixShimRefreshResult =
747
751
  | { installed: true; message: string }
748
752
  | { installed: false; deferred: boolean; message: string };
749
753
 
750
- function refreshObsoleteUnixShims(files: readonly ShimFileState[]): ObsoleteUnixShimRefreshResult {
754
+ function refreshObsoleteUnixShims(files: readonly ShimFileState[], runtime?: DurableBunRuntime): ObsoleteUnixShimRefreshResult {
751
755
  if (process.platform === "win32") {
752
756
  return { installed: false, deferred: false, message: "Codex autostart shim is already current." };
753
757
  }
@@ -786,7 +790,7 @@ function refreshObsoleteUnixShims(files: readonly ShimFileState[]): ObsoleteUnix
786
790
  throw new Error("Codex autostart shim upgrade could not fingerprint the staged wrapper");
787
791
  }
788
792
  entry.wrapperWriteStarted = true;
789
- const writtenInode = writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
793
+ const writtenInode = writeShim(file.wrapperPath, file.realPath ?? file.backupPath, runtime);
790
794
  const writtenWrapper = stableShimPathProbe(file.wrapperPath);
791
795
  if (!writtenWrapper || !isCurrentUnixShimProbe(writtenWrapper)) {
792
796
  throw new Error("Codex autostart shim upgrade could not fingerprint the regenerated wrapper");
@@ -876,6 +880,19 @@ function refreshObsoleteUnixShims(files: readonly ShimFileState[]): ObsoleteUnix
876
880
 
877
881
  function installCodexShimInternal(options: InstallCodexShimInternalOptions): { installed: boolean; message: string } {
878
882
  const existing = readState();
883
+ const filesBefore = existing ? stateFiles(existing) : [];
884
+ if (filesBefore.length > 0 && filesBefore.every(file => {
885
+ if (file.preserveOnly) return existsSync(file.backupPath) && !existsSync(file.originalPath);
886
+ const probe = stableShimPathProbe(file.wrapperPath);
887
+ return hasUsableBackingPath(file) && probe !== null && isHealthyShimProbe(probe, process.platform);
888
+ })) {
889
+ return { installed: false, message: "Codex autostart shim is already installed." };
890
+ }
891
+ const runtime = options.runtime ?? Object.freeze({ ...(options.selectRuntime ?? durableBunRuntime)() });
892
+ if (!options.runtimeAdmitted) {
893
+ const configDir = (options.configDir ?? getConfigDir)();
894
+ (options.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { rootWasAbsent: !existsSync(configDir) });
895
+ }
879
896
  if (existing) {
880
897
  const files = stateFiles(existing);
881
898
  if (!options.expectedReplacements && process.platform !== "win32") {
@@ -907,7 +924,7 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
907
924
  throw writeError;
908
925
  }
909
926
  };
910
- if (!applyGuardedRefreshTransaction(operations, options.beforeGuardedRefresh, commitState)) {
927
+ if (!applyGuardedRefreshTransaction(operations, options.beforeGuardedRefresh, commitState, runtime)) {
911
928
  return { installed: false, message: "Codex shim auto-restore deferred because tracked launchers changed." };
912
929
  }
913
930
  return {
@@ -916,7 +933,7 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
916
933
  };
917
934
  }
918
935
  let refreshed = false;
919
- for (const file of files) refreshed = refreshShimFile(file) || refreshed;
936
+ for (const file of files) refreshed = refreshShimFile(file, runtime) || refreshed;
920
937
  const allInstalled = files.every(file => file.preserveOnly
921
938
  ? existsSync(file.backupPath) && !existsSync(file.originalPath)
922
939
  : existsSync(file.wrapperPath)
@@ -988,7 +1005,7 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
988
1005
  }
989
1006
  if (!target.preserveOnly) {
990
1007
  entry.wrapperWriteStarted = true;
991
- const writtenInode = writeShim(target.wrapperPath, target.realPath ?? target.backupPath);
1008
+ const writtenInode = writeShim(target.wrapperPath, target.realPath ?? target.backupPath, runtime);
992
1009
  entry.writtenWrapperInode = writtenInode;
993
1010
  codexShimFreshWriteHookForTests?.();
994
1011
  if (process.platform !== "win32") {
@@ -1061,12 +1078,12 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
1061
1078
  };
1062
1079
  }
1063
1080
 
1064
- export function installCodexShim(): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } {
1081
+ export function installCodexShim(deps: RuntimePreflightDeps = {}): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } {
1065
1082
  if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath);
1066
- return installCodexShimInternal({ allowFreshInstall: true });
1083
+ return installCodexShimInternal({ ...deps, allowFreshInstall: true });
1067
1084
  }
1068
1085
 
1069
- export function autoRestoreCodexShim(options: {
1086
+ export function autoRestoreCodexShim(options: RuntimePreflightDeps & {
1070
1087
  enabled: () => boolean;
1071
1088
  stabilitySleep?: (ms: number) => void;
1072
1089
  /** Narrow deterministic seam used to hold the interprocess lock in tests. */
@@ -1133,6 +1150,14 @@ export function autoRestoreCodexShim(options: {
1133
1150
  };
1134
1151
  }
1135
1152
 
1153
+ const runtime = Object.freeze({ ...(options.selectRuntime ?? durableBunRuntime)() });
1154
+ try {
1155
+ const configDir = (options.configDir ?? getConfigDir)();
1156
+ (options.assertRuntimeWritable ?? assertSelectedRuntimeWritable)(runtime, configDir, { rootWasAbsent: !existsSync(configDir) });
1157
+ } catch (error) {
1158
+ if (error instanceof RuntimePreflightError) return { status: "deferred", message: error.message };
1159
+ throw error;
1160
+ }
1136
1161
  const lock = tryAcquireShimRestoreLock(options.beforeStaleRestoreLockDelete);
1137
1162
  if (!lock) return { status: "deferred" };
1138
1163
  try {
@@ -1159,6 +1184,7 @@ export function autoRestoreCodexShim(options: {
1159
1184
  expectedReplacements.set(path, secondProbe.fingerprint);
1160
1185
  }
1161
1186
  const result = installCodexShimInternal({
1187
+ runtime, runtimeAdmitted: true,
1162
1188
  allowFreshInstall: false,
1163
1189
  expectedReplacements,
1164
1190
  beforeGuardedRefresh: options.beforeGuardedRefresh,