@bitkyc08/opencodex 2.63.0 → 2.64.0-preview.20260923

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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-D5eN1TID.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-Bh_ErQDh.js} +1 -1
  10. package/gui/dist/assets/index-BEq4OCOz.js +86 -0
  11. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  12. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -1,9 +1,11 @@
1
+ import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
2
+ import { codexQuotaHasFreshUsage } from "./quota-observation-freshness";
1
3
  import { saveConfigPreservingClaudeCode } from "../config";
2
4
  import { isCodexAccountGenerationLive, registerCodexRefreshGenerationHandoff } from "./account-store";
3
5
  import { handOffThreadAffinityGeneration } from "./routing/thread-affinity";
4
6
  import { codexAccountLogLabel } from "./account-label";
5
7
  import { isCodexAccountPaused } from "./account-pause";
6
- import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority";
8
+ import { clearCodexAccountPin, pinnedCodexAccountId, codexAccountPriorityFailbackEnabled, CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "./account-priority";
7
9
  import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
8
10
  import { markAccountNeedsReauth } from "./account-runtime-state";
9
11
  import { codexAccountPinDrainReason } from "./routing/pin-drain";
@@ -109,6 +111,7 @@ import {
109
111
  pickUnboundStrategyAccount,
110
112
  preferModelEntitledAccount,
111
113
  sharedStateSelectionOptions,
114
+ sharesActiveSelection,
112
115
  strategySelectionOptionsForModelDetour,
113
116
  shouldFailover,
114
117
  peekAlternateCodexAccount,
@@ -492,6 +495,38 @@ export function resolveCodexAccountForThread(
492
495
  return resolution.status === "selected" ? resolution.accountId : null;
493
496
  }
494
497
 
498
+ /** The opt-in may preempt priority, never eligibility, a quota refusal or a manual pin. */
499
+ function pickAffinityPriorityFailback(
500
+ config: OcxConfig,
501
+ accountId: string,
502
+ now: number,
503
+ quotaScope?: CodexQuotaScope,
504
+ selectionOptions?: CodexAccountUsabilityOptions,
505
+ ): string | null {
506
+ if (!codexAccountPriorityFailbackEnabled(config, accountId)
507
+ || accountPoolStrategyForScope(config, quotaScope) !== "quota") return null;
508
+ if (!pickPriorityPreemption(config, accountId, now, quotaScope, selectionOptions)) return null;
509
+ // The selected tier can contain a stale cooler account beside a fresh one.
510
+ // Check every member before the lowest-usage picker sees it.
511
+ const candidates = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions).filter(id => {
512
+ if (!hasCodexQuotaHeadroom(config, id, selectionOptions, now)
513
+ || hasUnrecoveredCodexQuotaRefusal(id, quotaScope)
514
+ || shouldFailover(config, id, now)) return false;
515
+ const quota = getAccountQuota(id);
516
+ const plan = getPoolAccountPlanForSelection(config, id, selectionOptions);
517
+ // Retained bars alone are not a reason to discard a healthy conversation's cache.
518
+ if (!quota || !codexQuotaHasFreshUsage(quota, plan, now, CODEX_PRIORITY_FAILBACK_REFRESH_MS)
519
+ || !Number.isFinite(quota.updatedAt)
520
+ || now - quota.updatedAt >= CODEX_PRIORITY_FAILBACK_REFRESH_MS
521
+ || (quota.shortObservedAt !== undefined
522
+ && now - quota.shortObservedAt >= CODEX_PRIORITY_FAILBACK_REFRESH_MS)) return false;
523
+ const usage = computeCodexUsageScore(quota, plan, now);
524
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, id);
525
+ return !isUnknownUsage(usage) && usage < 100 && (threshold <= 0 || usage < threshold);
526
+ });
527
+ return pickLowestUsageAmong(config, candidates, selectionOptions, now);
528
+ }
529
+
495
530
  function previewReusableAffinityAccount(
496
531
  entry: ThreadAffinityEntry | undefined,
497
532
  config: OcxConfig,
@@ -528,10 +563,12 @@ function previewReusableAffinityAccount(
528
563
  if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") {
529
564
  return resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions) ?? entry.accountId;
530
565
  }
566
+ const recovered = pickAffinityPriorityFailback(config, entry.accountId, now, quotaScope, selectionOptions);
567
+ if (recovered) return recovered;
531
568
  // Quota strategy only: non-quota strategies keep affinity for ongoing threads
532
569
  // (new-session-only rotation — docs / affinity policy A).
533
570
  if (accountPoolStrategyForScope(config, quotaScope) === "quota") {
534
- const threshold = config.autoSwitchThreshold ?? 80;
571
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId);
535
572
  if (threshold > 0) {
536
573
  const usage = computeCodexUsageScore(
537
574
  getAccountQuota(entry.accountId),
@@ -564,7 +601,7 @@ function resetFirstAffinityReplacement(
564
601
  quotaScope?: CodexQuotaScope,
565
602
  selectionOptions?: CodexAccountUsabilityOptions,
566
603
  ): string | null {
567
- const threshold = config.autoSwitchThreshold ?? 80;
604
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId);
568
605
  if (threshold <= 0) return null;
569
606
  const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now);
570
607
  if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null;
@@ -652,7 +689,9 @@ function reevaluateAffinityQuota(
652
689
  return replacement;
653
690
  }
654
691
  if (strategy !== "quota") return null;
655
- const threshold = config.autoSwitchThreshold ?? 80;
692
+ const recovered = pickAffinityPriorityFailback(config, entry.accountId, now, quotaScope, selectionOptions);
693
+ if (recovered) { entry.lastReevalAt = now; return recovered; }
694
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId);
656
695
  const usage = threshold > 0
657
696
  ? computeCodexUsageScore(
658
697
  getAccountQuota(entry.accountId),
@@ -773,7 +812,7 @@ export function previewCodexAccountForRequest(
773
812
  }
774
813
  active = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions) ?? active;
775
814
 
776
- const threshold = config.autoSwitchThreshold ?? 80;
815
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, active);
777
816
  if (threshold > 0) {
778
817
  const usage = computeCodexUsageScore(
779
818
  getAccountQuota(active),
@@ -932,7 +971,7 @@ export function resolveCodexAccountForThreadDetailed(
932
971
  if (entry.transientHoldSince !== undefined) delete entry.transientHoldSince;
933
972
  if (entry.transientDetourAccountId !== undefined) delete entry.transientDetourAccountId;
934
973
  // Periodic quota re-eval: a long-lived bound thread must still switch when
935
- // it crosses autoSwitchThreshold, but only onto an account that has genuine
974
+ // it crosses its effective threshold, but only onto an account that has genuine
936
975
  // quota headroom AND is strictly cooler — moving to a destination still over
937
976
  // the threshold just trades the warmed prompt-cache prefix for an equally hot
938
977
  // account, which is the #4546 ping-pong.
@@ -944,7 +983,7 @@ export function resolveCodexAccountForThreadDetailed(
944
983
  // rotation is new-session-only (affinity policy A).
945
984
  const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions);
946
985
  if (cooler) {
947
- if (!isIndependentCodexQuotaScope(quotaScope)) {
986
+ if (!isIndependentCodexQuotaScope(quotaScope) && sharesActiveSelection(cooler, selectionOptions)) {
948
987
  promoteActiveCodexAccount(config, cooler);
949
988
  }
950
989
  bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks
@@ -990,7 +1029,9 @@ export function resolveCodexAccountForThreadDetailed(
990
1029
  && !shouldFailover(config, expiredDetour, now)
991
1030
  && !isCodexAccountSoftAvoided(expiredDetour, now)
992
1031
  ) {
993
- if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, expiredDetour);
1032
+ if (!isIndependentCodexQuotaScope(quotaScope) && sharesActiveSelection(expiredDetour, selectionOptions)) {
1033
+ promoteActiveCodexAccount(config, expiredDetour);
1034
+ }
994
1035
  bindThreadAffinity(threadId, expiredDetour, now, quotaScope);
995
1036
  return {
996
1037
  status: "selected",
@@ -1084,7 +1125,7 @@ export function resolveCodexAccountForThreadDetailed(
1084
1125
  // process-local cursor to whoever is actually serving and releases the pin; the
1085
1126
  // operator's persisted activeCodexAccountId is left untouched either way, which is
1086
1127
  // the thing the preference exists to protect.
1087
- promoteActiveCodexAccount(config, strategyPick);
1128
+ if (sharesActiveSelection(strategyPick, selectionOptions)) promoteActiveCodexAccount(config, strategyPick);
1088
1129
  }
1089
1130
  return { status: "selected", accountId: strategyPick, affinity: affinityAfterRelease(threadId, releaseReason) };
1090
1131
  }
@@ -1102,7 +1143,7 @@ export function resolveCodexAccountForThreadDetailed(
1102
1143
  return { status: "none", affinity: affinityOnNoAccount(threadId, releaseReason) };
1103
1144
  }
1104
1145
  if (!isIndependentCodexQuotaScope(quotaScope) && !modelScopedSelection) {
1105
- setActiveCodexAccount(config, selected);
1146
+ if (sharesActiveSelection(selected, selectionOptions)) setActiveCodexAccount(config, selected);
1106
1147
  }
1107
1148
  active = selected;
1108
1149
  }
@@ -1123,7 +1164,7 @@ export function resolveCodexAccountForThreadDetailed(
1123
1164
  && preserveSharedSelectionForModelDetour
1124
1165
  && activeHealthyForSharedSelection;
1125
1166
  if (!isIndependentCodexQuotaScope(quotaScope) && !modelOnlyMove) {
1126
- setActiveCodexAccount(config, fallback);
1167
+ if (sharesActiveSelection(fallback, selectionOptions)) setActiveCodexAccount(config, fallback);
1127
1168
  }
1128
1169
  active = fallback;
1129
1170
  } else if (
@@ -1157,6 +1198,7 @@ export function resolveCodexAccountForThreadDetailed(
1157
1198
  if (
1158
1199
  !preserveSharedSelectionForModelDetour
1159
1200
  && !isIndependentCodexQuotaScope(quotaScope)
1201
+ && sharesActiveSelection(preempted, selectionOptions)
1160
1202
  ) {
1161
1203
  // Preemption is an automatic pick competing with the operator, so it yields.
1162
1204
  if (!manualPreferenceBlocks(POOL_KEY_CODEX, preempted)) {
@@ -27,6 +27,7 @@ import {
27
27
  type CodexAccountUsabilityOptions,
28
28
  } from "./account-usability";
29
29
  import { isCodexAccountPaused } from "./account-pause";
30
+ import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
30
31
  import { slugEquals } from "../providers/slug-codec";
31
32
  import { isThreadSpawnRequest } from "../server/effort-policy";
32
33
  import { PROVIDER_REGISTRY } from "../providers/registry";
@@ -157,8 +158,8 @@ export function buildSubagentModelChain(
157
158
  return normalizedChain(primary, config, extraFallback);
158
159
  }
159
160
 
160
- function quotaThreshold(config: OcxConfig): number {
161
- const threshold = config.autoSwitchThreshold ?? 80;
161
+ function quotaThreshold(config: OcxConfig, accountId: string): number {
162
+ const threshold = getEffectiveCodexAutoSwitchThreshold(config, accountId);
162
163
  return threshold > 0 ? threshold : Number.POSITIVE_INFINITY;
163
164
  }
164
165
 
@@ -233,7 +234,7 @@ export function isNativeModelQuotaExhausted(
233
234
  // rather than letting the scorer read wall time - the two would silently diverge.
234
235
  const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId), now);
235
236
  if (usage >= CODEX_UNKNOWN_USAGE_SCORE) return false;
236
- return usage >= quotaThreshold(config);
237
+ return usage >= quotaThreshold(config, resolvedAccountId);
237
238
  }
238
239
 
239
240
  export function isModelHealthBlocked(
package/src/codex/sync.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  import { admitCodexWrite, type CodexAdmission } from "./admission";
15
15
  import type { CodexCatalogSyncOptions } from "./catalog/sync";
16
16
  import { resetCodexAppServerCatalogStateCache } from "./app-server-processes";
17
+ import { providerUsesReasoningMetadata, refreshReasoningMetadata } from "../providers/reasoning-metadata";
17
18
 
18
19
  export interface CodexSyncResult {
19
20
  /**
@@ -67,13 +68,21 @@ interface CodexSyncDeps {
67
68
  admitCodexWrite?: () => CodexSyncAdmission;
68
69
  currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider;
69
70
  collectCodexHomeDiagnostic?: typeof collectOrcaCodexHomeDiagnostic;
71
+ refreshReasoningMetadata?: typeof refreshReasoningMetadata;
70
72
  }
71
73
 
72
74
  const defaultDeps: CodexSyncDeps = {
73
75
  refreshCodexModelCatalog,
74
76
  injectCodexConfig,
77
+ refreshReasoningMetadata,
75
78
  };
76
79
 
80
+ async function refreshReasoningMetadataForSync(config: OcxConfig, deps: CodexSyncDeps): Promise<void> {
81
+ if (Object.values(config.providers).some(providerUsesReasoningMetadata)) {
82
+ await deps.refreshReasoningMetadata?.({ waitMs: 2_000 });
83
+ }
84
+ }
85
+
77
86
  function reportCodexHomeTarget(
78
87
  log: Pick<Console, "log" | "error"> | null,
79
88
  collectDiagnostic: typeof collectOrcaCodexHomeDiagnostic,
@@ -234,6 +243,7 @@ export async function syncModelsToCodex(
234
243
  }
235
244
 
236
245
  applyProxyEnv(config); // `ocx ensure`/`ocx sync` fetch provider models outside the server process
246
+ await refreshReasoningMetadataForSync(config, deps);
237
247
  let added = 0;
238
248
  let catalogPath: string | null = null;
239
249
  let catalogPathForInjection: string | null | undefined;
@@ -339,6 +349,7 @@ async function refreshCatalogForSync(
339
349
  let refreshOutcome: "committed" | "refused" | undefined;
340
350
  let comboOmissions: ComboCatalogOmission[] = [];
341
351
  try {
352
+ await refreshReasoningMetadataForSync(config, deps);
342
353
  const cat = await deps.refreshCodexModelCatalog(config, undefined, catalogOptions);
343
354
  refreshOutcome = cat.refreshOutcome;
344
355
  added = cat.added;
@@ -18,6 +18,10 @@ export interface WindowsInstallationFileRequest {
18
18
  readonly path: string;
19
19
  readonly maxBytes: number;
20
20
  readonly hashOnly?: boolean;
21
+ /** Validate and hold the path without reading its contents. */
22
+ readonly metadataOnly?: boolean;
23
+ /** Read at most maxBytes from the start instead of refusing an oversized file. */
24
+ readonly prefixOnly?: boolean;
21
25
  }
22
26
  export interface WindowsInstallationFileIdentity {
23
27
  readonly volumeSerial: string;
@@ -31,10 +35,20 @@ export type WindowsInstallationFilesResult =
31
35
  path: string; identity: WindowsInstallationFileIdentity; bytes: Uint8Array; digest: string;
32
36
  }[] }
33
37
  | { kind: "refused"; reason: "unsupported-platform" | "invalid-request" | "native-api-unavailable"
34
- | "volume-unavailable" | "open-refused" | "reparse-point" | "not-regular-file"
38
+ | "volume-unavailable" | "not-found" | "open-refused" | "reparse-point" | "not-regular-file"
35
39
  | "size-limit" | "read-failed" | "identity-changed" | "inspection-failed" };
36
40
 
37
41
  type Refusal = Extract<WindowsInstallationFilesResult, { kind: "refused" }>["reason"];
42
+ /** Only these two NTSTATUS values prove that a candidate or its ancestor is absent. */
43
+ export function ntCreateFileRefusal(status: number): Refusal {
44
+ switch (status >>> 0) {
45
+ case 0xc0000034: // STATUS_OBJECT_NAME_NOT_FOUND
46
+ case 0xc000003a: // STATUS_OBJECT_PATH_NOT_FOUND
47
+ return "not-found";
48
+ default:
49
+ return "open-refused";
50
+ }
51
+ }
38
52
  class InspectionRefusal extends Error {
39
53
  constructor(readonly reason: Refusal) { super(reason); }
40
54
  }
@@ -76,6 +90,8 @@ export async function inspectWindowsInstallationFiles(
76
90
  const parsedPath = request && components(request.path);
77
91
  if (!parsedPath || !Number.isSafeInteger(request.maxBytes) || request.maxBytes < 0
78
92
  || (request.hashOnly !== undefined && typeof request.hashOnly !== "boolean")
93
+ || (request.metadataOnly !== undefined && typeof request.metadataOnly !== "boolean")
94
+ || (request.prefixOnly !== undefined && typeof request.prefixOnly !== "boolean")
79
95
  || request.maxBytes > (request.hashOnly ? 256 * MIB : MIB)) return null;
80
96
  ceiling += request.maxBytes;
81
97
  return parsedPath;
@@ -163,7 +179,7 @@ export async function inspectWindowsInstallationFiles(
163
179
  // Share READ only: while held, writes/reparse edits and delete/rename opens are refused.
164
180
  const result = nt!.symbols.NtCreateFile!(ffi.ptr(output), 0x100081, ffi.ptr(attributes),
165
181
  ffi.ptr(status), null, 0, 1, 1, 0x200020 | (directory ? 1 : 0), null, 0);
166
- if (result < 0) throw new InspectionRefusal("open-refused");
182
+ if (result < 0) throw new InspectionRefusal(ntCreateFileRefusal(result));
167
183
  const handle = keep(output.readBigUInt64LE(0));
168
184
  inspect(handle, directory);
169
185
  return handle;
@@ -184,29 +200,35 @@ export async function inspectWindowsInstallationFiles(
184
200
  }
185
201
  const handle = relativeOpen(parent, names[names.length - 1]!, false);
186
202
  const identity = inspect(handle, false);
187
- if (identity.size > request.maxBytes) throw new InspectionRefusal("size-limit");
203
+ if (!request.metadataOnly && !(request.prefixOnly && !request.hashOnly)
204
+ && identity.size > request.maxBytes) throw new InspectionRefusal("size-limit");
188
205
  return { request, handle, identity };
189
206
  });
190
207
  openedForTests?.();
191
208
  const observed = files.map(({ request, handle, identity }) => {
192
209
  const hash = createHash("sha256");
193
- const bytes = request.hashOnly ? new Uint8Array() : new Uint8Array(identity.size);
194
- const chunk = Buffer.alloc(Math.min(MIB, Math.max(1, identity.size)));
210
+ if (request.metadataOnly) return { path: request.path, identity, bytes: new Uint8Array(), digest: "" };
211
+ const truncated = Boolean(request.prefixOnly) && !request.hashOnly && identity.size > request.maxBytes;
212
+ const readLimit = truncated ? request.maxBytes : identity.size;
213
+ const bytes = request.hashOnly ? new Uint8Array() : new Uint8Array(readLimit);
214
+ const chunk = Buffer.alloc(Math.min(MIB, Math.max(1, readLimit)));
195
215
  const read = Buffer.alloc(4);
196
216
  let offset = 0;
197
- while (offset < identity.size) {
198
- const length = Math.min(chunk.length, identity.size - offset);
217
+ while (offset < readLimit) {
218
+ const length = Math.min(chunk.length, readLimit - offset);
199
219
  if (!k.ReadFile!(handle, ffi.ptr(chunk), length, ffi.ptr(read), null)) throw new InspectionRefusal("read-failed");
200
220
  const count = read.readUInt32LE(0);
201
221
  if (!count || count > length) throw new InspectionRefusal("read-failed");
202
- hash.update(chunk.subarray(0, count));
203
222
  if (!request.hashOnly) bytes.set(chunk.subarray(0, count), offset);
223
+ if (!truncated) hash.update(chunk.subarray(0, count));
204
224
  offset += count;
205
225
  }
206
- if (!k.ReadFile!(handle, ffi.ptr(chunk), 1, ffi.ptr(read), null) || read.readUInt32LE(0) !== 0) {
207
- throw new InspectionRefusal("identity-changed");
226
+ if (!truncated) {
227
+ if (!k.ReadFile!(handle, ffi.ptr(chunk), 1, ffi.ptr(read), null) || read.readUInt32LE(0) !== 0) {
228
+ throw new InspectionRefusal("identity-changed");
229
+ }
208
230
  }
209
- return { path: request.path, identity, bytes, digest: hash.digest("hex") };
231
+ return { path: request.path, identity, bytes, digest: truncated ? "" : hash.digest("hex") };
210
232
  });
211
233
  for (const file of files) {
212
234
  if (JSON.stringify(inspect(file.handle, false)) !== JSON.stringify(file.identity)) {
@@ -20,6 +20,10 @@
20
20
  * enumeration of those was already incomplete once. Hashing the computed
21
21
  * candidate bytes closes the class instead of the instance — an input that
22
22
  * changes the output changes the id whether or not anyone remembered to list it.
23
+ * The pre-lock comparison id admits the original candidate. If a native feature
24
+ * transition changes that candidate under the lock, the coordinator publishes
25
+ * a new id derived from the bytes it commits; admission remains the stale-input
26
+ * guard and is not reused as a committed-byte witness.
23
27
  */
24
28
  import { createHash } from "node:crypto";
25
29
 
@@ -110,9 +110,13 @@ export function concreteComboRequestBody(
110
110
  return clone;
111
111
  }
112
112
  if (reasoning === undefined) {
113
- clone.reasoning = { effort: resolvedEffort };
113
+ clone.reasoning = { effort: resolvedEffort, summary: "auto" };
114
114
  } else {
115
- clone.reasoning = { ...(reasoning as Record<string, unknown>), effort: resolvedEffort };
115
+ clone.reasoning = {
116
+ ...(reasoning as Record<string, unknown>),
117
+ effort: resolvedEffort,
118
+ ...((reasoning as Record<string, unknown>).summary === undefined ? { summary: "auto" } : {}),
119
+ };
116
120
  }
117
121
  return clone;
118
122
  }
@@ -4,6 +4,7 @@ import { getConfigDir } from "../config/paths";
4
4
  import {
5
5
  TIMELINE_HOURS,
6
6
  isTimelineModelId,
7
+ normalizeTimelineModelId,
7
8
  type TimelineAggregation,
8
9
  type TimelineGrouping,
9
10
  type TimelineMetric,
@@ -104,7 +105,11 @@ export function applyCompanionSettingsPatch(
104
105
  const error = validateValue(key as keyof CompanionSettings, values[key]);
105
106
  if (error) return invalid(error);
106
107
  }
107
- return { ...current, ...values } as CompanionSettings;
108
+ const next = { ...current, ...values } as CompanionSettings;
109
+ // Every companion re-filters timeline rows against these ids, so a selection saved while the chart
110
+ // still split pool accounts has to name the merged row the timeline now returns.
111
+ if (next.models !== null) next.models = [...new Set(next.models.map(normalizeTimelineModelId))];
112
+ return next;
108
113
  }
109
114
 
110
115
  export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null; corrupt?: true } {
@@ -277,8 +277,9 @@ function atomicWriteFileToTarget(
277
277
  target: string,
278
278
  io?: AtomicWriteIO,
279
279
  hooks: AtomicWriteHooks = {},
280
+ recordOwnership = true,
280
281
  ): void {
281
- recordOwnedConfigPath(getConfigDir(), path);
282
+ if (recordOwnership) recordOwnedConfigPath(getConfigDir(), path);
282
283
  assertResolvedTargetAllowed(path, target);
283
284
  const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
284
285
  let hardened = false;
@@ -399,6 +400,16 @@ export function atomicWriteFileNoFollow(
399
400
  atomicWriteFileToTarget(path, content, join(resolveWriteTarget(dirname(path)), basename(path)), io, hooks);
400
401
  }
401
402
 
403
+ /**
404
+ * The no-follow replacement above, for rewriting a file whose uninstall ownership must stay as
405
+ * it was: it does not record the path in the owner manifest. Used to rewrite the OAuth downgrade
406
+ * backup, which a pre-registration install may have left deliberately unclaimed; claiming it
407
+ * here would let a later uninstall delete recovery data it never owned.
408
+ */
409
+ export function atomicWriteFileNoFollowUnclaimed(path: string, content: string): void {
410
+ atomicWriteFileToTarget(path, content, join(resolveWriteTarget(dirname(path)), basename(path)), undefined, {}, false);
411
+ }
412
+
402
413
  export interface AtomicWriteAsyncIO {
403
414
  write: (path: string, content: string) => void | Promise<void>;
404
415
  harden: (path: string) => void | Promise<void>;
@@ -0,0 +1,29 @@
1
+ import { setConfiguredNativeOpenAiModels } from "../codex/catalog/native-models";
2
+ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers-destination";
3
+ import type { OcxConfig, OcxProviderConfig } from "../types";
4
+ import { refreshUserCostOverlays } from "../usage/user-cost-overlays";
5
+
6
+ /**
7
+ * Bare `gpt-*` ids listed under `providers.openai.models` that become configured natives.
8
+ *
9
+ * Only the canonical Codex forward provider qualifies (an omitted authMode is the registry's
10
+ * forward default): `providers.openai.models` is otherwise ignored for that provider, and on any
11
+ * other shape the list means routed models, which must not turn into native rows.
12
+ */
13
+ export function configuredNativeOpenAiModelIds(config: Pick<OcxConfig, "providers">): string[] {
14
+ const provider = config.providers?.[OPENAI_CODEX_PROVIDER_ID] as OcxProviderConfig | undefined;
15
+ if (!provider || provider.disabled === true || !Array.isArray(provider.models)) return [];
16
+ const canonical = provider.authMode === undefined ? { ...provider, authMode: "forward" as const } : provider;
17
+ if (!isCanonicalOpenAiForwardProvider(canonical)) return [];
18
+ return provider.models.flatMap(id => typeof id === "string" ? [id.trim()] : []);
19
+ }
20
+
21
+ /**
22
+ * Rebuild every process-local registry derived from a committed config: user price overlays and
23
+ * configured native GPT models. Called wherever a loaded, persisted or reconciled config becomes
24
+ * current, so the two never disagree about which config is live.
25
+ */
26
+ export function refreshConfigDerivedRegistries(config: OcxConfig): void {
27
+ refreshUserCostOverlays(config);
28
+ setConfiguredNativeOpenAiModels(configuredNativeOpenAiModelIds(config));
29
+ }
@@ -52,6 +52,7 @@ import {
52
52
  clientConnectionSchema,
53
53
  CODEX_ACCOUNT_PIN_PATTERN,
54
54
  codexAccountPrioritiesSchema,
55
+ codexAccountAutoSwitchThresholdsSchema,
55
56
  codexPoolSchema,
56
57
  codexQuotaAutoRefreshSchema,
57
58
  credentialGroupsSchema,
@@ -347,6 +348,13 @@ function codexAccountPrioritiesError(value: unknown): string | null {
347
348
  return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
348
349
  }
349
350
  }
351
+ if (raw.codexAccountAutoSwitchThresholds !== undefined) {
352
+ const parsed = codexAccountAutoSwitchThresholdsSchema.safeParse(raw.codexAccountAutoSwitchThresholds);
353
+ if (!parsed.success) {
354
+ return schemaDiagnosticsError(parsed.error)
355
+ .replace("schema_invalid: ", "schema_invalid: codexAccountAutoSwitchThresholds.");
356
+ }
357
+ }
350
358
  // Tested as a string rather than coerced: `String(123)` matches the id pattern, so a
351
359
  // coercing guard waves a non-string pin through to the schema, where `.catch(undefined)`
352
360
  // drops it and reports the write as a success — the exact silent-degrade this guards.
@@ -357,6 +365,14 @@ function codexAccountPrioritiesError(value: unknown): string | null {
357
365
  return null;
358
366
  }
359
367
 
368
+ function codexAccountPriorityFailbackError(value: unknown): string | null {
369
+ const raw = rawConfigRecord(value);
370
+ if (!raw || !Object.hasOwn(raw, "codexAccountPriorityFailback")) return null;
371
+ const enabled = raw.codexAccountPriorityFailback;
372
+ if (enabled === undefined || typeof enabled === "boolean") return null;
373
+ return "schema_invalid: codexAccountPriorityFailback: must be a boolean or omitted";
374
+ }
375
+
360
376
  /**
361
377
  * Same reasoning as {@link codexAccountPrioritiesError}, plus one of its own. The read
362
378
  * path drops an invalid grouping, so a degraded write would erase a declaration the
@@ -595,6 +611,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
595
611
  ?? codexPoolError(value)
596
612
  ?? googleAntigravityStaticCatalogVersionError(value)
597
613
  ?? codexAccountPrioritiesError(value)
614
+ ?? codexAccountPriorityFailbackError(value)
598
615
  ?? poolCredentialGroupsError(value)
599
616
  ?? codexQuotaAutoRefreshError(value)
600
617
  ?? codexAccountPickerEnabledError(value)
@@ -1,8 +1,12 @@
1
1
  import type { OcxConfig, OcxProviderConfig } from "../types";
2
2
  import { configReasoningPinsConfigError } from "./provider-validation";
3
3
  import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration";
4
- import { refreshPreservedProviderOwner, refreshUserCostOverlays } from "../usage/user-cost-overlays";
4
+ import { refreshPreservedProviderOwner } from "../usage/user-cost-overlays";
5
+ import { refreshConfigDerivedRegistries } from "./derived-registries";
5
6
  import {
7
+ applyConfigObjectChildDeletions,
8
+ clearPendingConfigObjectChildDeletions,
9
+ prepareConfigObjectChildDeletionRebase,
6
10
  clearPendingConfigTopLevelDeletions,
7
11
  configHasRebaseProvenance,
8
12
  configRebaseDeletionKeys,
@@ -357,12 +361,14 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
357
361
  ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}),
358
362
  });
359
363
 
364
+ const childDeletions = prepareConfigObjectChildDeletionRebase(config);
360
365
  reconcileConfigRecord(
361
366
  config as unknown as Record<string, unknown>,
362
367
  persistedBaseline as unknown as Record<string, unknown>,
363
368
  persisted as unknown as Record<string, unknown>,
364
369
  new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]),
365
370
  );
371
+ applyConfigObjectChildDeletions(config, childDeletions);
366
372
 
367
373
  if (claudeGuardArmed && !pendingLiveClaudeMutation) {
368
374
  if (persisted.claudeCode === undefined) delete config.claudeCode;
@@ -372,7 +378,7 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
372
378
  // The reconciliation may have adopted a providers.<name>.modelCosts edit made
373
379
  // by a cooperating process while the OAuth login was pending; keep the overlay
374
380
  // registry (and the usage-cache overlay version) in sync with the live config.
375
- refreshUserCostOverlays(config);
381
+ refreshConfigDerivedRegistries(config);
376
382
  }
377
383
 
378
384
  /**
@@ -420,6 +426,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
420
426
  const pinError = configReasoningPinsConfigError(config);
421
427
  if (pinError) throw new Error(pinError);
422
428
  withConfigMutationLockSync(() => {
429
+ const childDeletions = prepareConfigObjectChildDeletionRebase(config);
423
430
  const bindingBaseline = persistedLiveServerBinding.get(config);
424
431
  // One authoritative pre-write read feeds both the live-config reconciliation and
425
432
  // custom-model deletion migration. A second read could observe different bytes.
@@ -477,6 +484,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
477
484
  }
478
485
  }
479
486
  }
487
+ applyConfigObjectChildDeletions(config, childDeletions);
480
488
  if (claudeCodeBaseline.has(config)) {
481
489
  if (onDisk !== undefined) {
482
490
  const baseline = claudeCodeBaseline.get(config);
@@ -516,6 +524,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
516
524
  else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance);
517
525
  liveConfigBaseline.set(config, structuredClone(projectedConfig));
518
526
  }
527
+ clearPendingConfigObjectChildDeletions(config);
519
528
  clearPendingConfigTopLevelDeletions(config);
520
529
  });
521
530
  }
@@ -17,7 +17,7 @@ import { MODEL_ALIAS_PATTERN } from "../providers/default-aliases";
17
17
  import { MODEL_DISCOVERY_MAX_MODELS } from "../providers/model-discovery-limits";
18
18
  import { getProviderRegistryEntry, providerMatchesRegistryTransport, registryModelServiceTierCapabilityApplies } from "../providers/registry";
19
19
  import { isCodexReasoningEffort } from "../reasoning-effort";
20
- import { refreshUserCostOverlays } from "../usage/user-cost-overlays";
20
+ import { refreshConfigDerivedRegistries } from "./derived-registries";
21
21
  import { type OcxClaudeCodeConfig, type OcxConfig } from "../types";
22
22
  import {
23
23
  agentTaskRecoverySchema,
@@ -26,6 +26,7 @@ import {
26
26
  isUsableApiKeySecret,
27
27
  managementIngressSchema,
28
28
  codexPoolSchema,
29
+ codexAccountAutoSwitchThresholdsSchema,
29
30
  providerModelCostsConfigError,
30
31
  credentialGroupsSchema,
31
32
  hubConfigSchema,
@@ -389,6 +390,12 @@ export function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validat
389
390
  if (raw !== undefined && validated.codexAccountPriorities === undefined) {
390
391
  warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled");
391
392
  }
393
+ const rawThresholds = record?.codexAccountAutoSwitchThresholds;
394
+ if (rawThresholds !== undefined && !codexAccountAutoSwitchThresholdsSchema.safeParse(rawThresholds).success) {
395
+ warnings.push(validated.codexAccountAutoSwitchThresholds === undefined
396
+ ? "codexAccountAutoSwitchThresholds is invalid (expected account ids mapped to integers between 0 and 100) — per-account usage thresholds are disabled"
397
+ : "codexAccountAutoSwitchThresholds contains invalid entries (expected account ids mapped to integers between 0 and 100) — invalid entries were ignored");
398
+ }
392
399
  return warnings;
393
400
  }
394
401
 
@@ -934,6 +941,6 @@ export function sanitizeModelDisplayNamesForLoad(raw: unknown): void {
934
941
 
935
942
  /** Refresh the user cost-overlay registry from `config` and return it unchanged. */
936
943
  export function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
937
- refreshUserCostOverlays(config);
944
+ refreshConfigDerivedRegistries(config);
938
945
  return config;
939
946
  }
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { configReasoningPinsConfigError } from "./provider-validation";
3
3
  import type { OcxConfig } from "../types";
4
- import { refreshUserCostOverlays, withPreservedDiskOnlyProviders } from "../usage/user-cost-overlays";
4
+ import { withPreservedDiskOnlyProviders } from "../usage/user-cost-overlays";
5
+ import { refreshConfigDerivedRegistries } from "./derived-registries";
5
6
  import { atomicWriteFile, isMissingPathError } from "./atomic-write";
6
7
  import { getConfigPath } from "./paths";
7
8
  import { configRebaseDeletionKeys, projectConfigRebaseProvenance } from "./rebase-provenance";
@@ -81,12 +82,12 @@ export function persistConfigUnlocked(config: OcxConfig): boolean {
81
82
  // the same bytes (e.g. before a proxy notification), and Logs/Usage must
82
83
  // adopt the overlay without waiting for a changed save or restart.
83
84
  if (unchanged) {
84
- refreshUserCostOverlays(persisted);
85
+ refreshConfigDerivedRegistries(persisted);
85
86
  return false;
86
87
  }
87
88
  atomicWriteFile(configPath, bytes);
88
89
  // For changed saves, refresh only AFTER the write succeeded so a failed
89
90
  // write cannot leave estimates reflecting configuration never persisted.
90
- refreshUserCostOverlays(persisted);
91
+ refreshConfigDerivedRegistries(persisted);
91
92
  return true;
92
93
  }