@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -11,6 +11,7 @@ import {
11
11
  clearModelCache,
12
12
  clearProviderDiscoveryStatus,
13
13
  captureModelCacheGeneration,
14
+ observeModelCacheRevision,
14
15
  DEFAULT_MODEL_CACHE_TTL_MS,
15
16
  getFreshCached,
16
17
  getStaleCached,
@@ -51,7 +52,7 @@ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../
51
52
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
52
53
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
53
54
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
54
- import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
55
+ import { cursorLiveRosterScope, recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
55
56
  import { fetchQoderModels } from "../../adapters/qoder/live-models";
56
57
  import { resolveQoderProfile } from "../../adapters/qoder/profiles";
57
58
  import { fetchDevinUsableModels } from "../../adapters/devin/live-models";
@@ -108,6 +109,14 @@ import { captureProviderGather, materializeCapturedHeaders } from "./gather-capt
108
109
  export interface ProviderModelsResult {
109
110
  readonly models: CatalogModel[];
110
111
  readonly outcome: CatalogGatherProviderModelOutcome;
112
+ /**
113
+ * The provider's cache content revision as it stood when these rows were chosen.
114
+ *
115
+ * Sampled here rather than by the caller because only this point is synchronous with the
116
+ * choice. A caller reading it after its own await can observe a revision another flight
117
+ * published in between, and would then record these rows under that flight's identity.
118
+ */
119
+ readonly contentRevision: string;
111
120
  }
112
121
  export const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" };
113
122
 
@@ -145,7 +154,11 @@ export async function fetchProviderModelsWithAuth(
145
154
  const observed = (
146
155
  models: CatalogModel[],
147
156
  state: CatalogGatherProviderModelOutcome["state"],
148
- ): ProviderModelsResult => ({ models, outcome: { provider: name, state } });
157
+ ): ProviderModelsResult => ({
158
+ models,
159
+ outcome: { provider: name, state },
160
+ contentRevision: observeModelCacheRevision(name),
161
+ });
149
162
  // Capture before any credential refresh or outbound await. OAuth account changes clear this
150
163
  // generation, so a request started with the former account cannot later publish its result.
151
164
  const cacheGeneration = captureModelCacheGeneration(name);
@@ -276,15 +289,19 @@ export async function fetchProviderModelsWithAuth(
276
289
  }
277
290
  if (prov.adapter === "devin") {
278
291
  if (!apiKey) return observed(configured, "degraded");
279
- const cachedDevin = getFreshCached(name, ttlMs);
292
+ // Devin's usable-model list is entitlement-specific. Bind cache reads/writes to an
293
+ // irreversible credential fingerprint so a credential switch cannot observe another
294
+ // account's roster or stale fallback (the Qoder precedent above).
295
+ const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
296
+ const cachedDevin = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
280
297
  if (cachedDevin) {
281
298
  return observed(
282
299
  withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)),
283
300
  "authoritative",
284
301
  );
285
302
  }
286
- if (isModelsFetchCoolingDown(name)) {
287
- const cooling = getStaleCached(name);
303
+ if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity)) {
304
+ const cooling = getStaleCached(name, authorityIdentity);
288
305
  return observed(
289
306
  withConfiguredRetention(
290
307
  cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured,
@@ -326,17 +343,17 @@ export async function fetchProviderModelsWithAuth(
326
343
  } as CatalogModel;
327
344
  });
328
345
  const forCache = withConfiguredRetention(result, { retainComboTargets: false });
329
- if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
346
+ if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
330
347
  return observed(withConfiguredRetention(configured), "degraded");
331
348
  }
332
349
  markProviderDiscoveryOk(name, liveResult.models.length);
333
350
  return observed(withConfiguredRetention(forCache), "authoritative");
334
351
  }
335
352
  if (isCurrentCacheGeneration()) {
336
- markModelsFetchFailure(name);
353
+ markModelsFetchFailure(name, undefined, authorityIdentity);
337
354
  markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" });
338
355
  }
339
- const stale = getStaleCached(name);
356
+ const stale = getStaleCached(name, authorityIdentity);
340
357
  return observed(
341
358
  withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured),
342
359
  "degraded",
@@ -348,15 +365,19 @@ export async function fetchProviderModelsWithAuth(
348
365
  // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
349
366
  // suffix) but filter the static seed to the bases the account actually has — so models not on the
350
367
  // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
351
- const cachedCursor = getFreshCached(name, ttlMs);
368
+ // The roster is entitlement-specific, so the cache entry is bound to an irreversible
369
+ // credential fingerprint: a credential switch must not observe the previous account's
370
+ // plan roster, its stale fallback, or its failure cooldown suppression.
371
+ const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
372
+ const cachedCursor = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
352
373
  if (cachedCursor) {
353
374
  return observed(
354
375
  withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias)),
355
376
  "authoritative",
356
377
  );
357
378
  }
358
- if (isModelsFetchCoolingDown(name)) {
359
- const cooling = getStaleCached(name);
379
+ if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity)) {
380
+ const cooling = getStaleCached(name, authorityIdentity);
360
381
  return observed(
361
382
  withConfiguredRetention(
362
383
  cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
@@ -372,32 +393,37 @@ export async function fetchProviderModelsWithAuth(
372
393
  ...(cursorFetch ? { fetch: cursorFetch } : {}),
373
394
  });
374
395
  if (liveResult.ok) {
375
- const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
396
+ const available = filterCursorConfiguredModelsByLiveDiscovery(
397
+ configured,
398
+ liveResult.models,
399
+ liveResult.maxModeModels ?? [],
400
+ );
376
401
  const result = available.length > 0 ? available : configured;
377
402
  // Cache the discovery-filtered roster without combo retention so a later
378
403
  // gather can re-apply the current capture's retain set on read.
379
404
  const forCache = withConfiguredRetention(result, { retainComboTargets: false });
380
- if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
405
+ if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
381
406
  return observed(withConfiguredRetention(configured), "degraded");
382
407
  }
383
408
  // Publish roster-derived state only for a discovery the cache accepted: a stale
384
409
  // in-flight capture (generation revoked by a credential/config change) must not
385
410
  // overwrite the spelling or Max-Mode evidence of the newer one.
386
- recordLiveCursorClaudeModels(liveResult.models);
411
+ const liveRosterScope = { provider: name, key: cursorLiveRosterScope(prov.baseUrl, apiKey) };
412
+ recordLiveCursorClaudeModels(liveResult.models, liveRosterScope);
387
413
  // Live Max-Mode evidence feeds the umbrella resolver's ultra gate
388
414
  // (devlog 260828_cursor_umbrella_catalog; union with static evidence).
389
- recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []);
415
+ recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? [], liveRosterScope);
390
416
  markProviderDiscoveryOk(name, liveResult.models.length);
391
417
  return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
392
418
  }
393
419
  if (isCurrentCacheGeneration()) {
394
- markModelsFetchFailure(name);
420
+ markModelsFetchFailure(name, undefined, authorityIdentity);
395
421
  markProviderDiscoveryFailed(name, { reason: "provider" });
396
422
  console.warn(
397
423
  `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
398
424
  );
399
425
  }
400
- const staleCursor = getStaleCached(name);
426
+ const staleCursor = getStaleCached(name, authorityIdentity);
401
427
  return observed(
402
428
  withConfiguredRetention(
403
429
  staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
@@ -15,9 +15,9 @@ import {
15
15
  availableAccountGatedNativeModels,
16
16
  codexModelEntitlementStateForAccount,
17
17
  isCodexModelEntitlementSnapshotCurrent,
18
- resolveCodexModelEntitlements,
19
18
  type CodexModelEntitlementSnapshot,
20
19
  } from "../model-entitlements";
20
+ import { resolveAdmittedCodexModelEntitlements } from "../model-entitlement-admission";
21
21
  import { isAccountNeedsReauth } from "../account-runtime-state";
22
22
  import { codexRuntimeStatePath } from "../runtime";
23
23
  import {
@@ -59,10 +59,12 @@ import {
59
59
  type CatalogWritePermit,
60
60
  } from "../catalog-write-serialization";
61
61
  import {
62
+ preparedBytesDifferFromDisk,
62
63
  publishHashedCodexCatalogBackup,
63
64
  publishLegacyCodexCatalogBackup,
64
65
  replaceActiveCodexCatalog,
65
66
  replaceCodexModelsCache,
67
+ type PreparedCatalogFileWrite,
66
68
  } from "../internal/catalog-writer";
67
69
  import { visibleCodexAccountSelectors } from "./account-models";
68
70
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
@@ -234,24 +236,6 @@ function revalidateRetainedCatalogSync(
234
236
  };
235
237
  }
236
238
 
237
- /**
238
- * Exact bytes currently on disk at `path`, or null when unreadable/absent.
239
- *
240
- * Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
241
- * substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
242
- * equal to prepared content holding a legitimately encoded U+FFFD. Comparing the
243
- * decoded strings would then classify a malformed catalog as identical, skip the
244
- * atomic repair write, and leave the corruption on disk while reporting
245
- * `catalogWritten: false`.
246
- */
247
- function currentCatalogFileContent(path: string): Buffer | null {
248
- try {
249
- return readFileSync(path);
250
- } catch {
251
- return null;
252
- }
253
- }
254
-
255
239
  function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null {
256
240
  if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) {
257
241
  try {
@@ -558,15 +542,12 @@ function writeRetainedCatalogSync({
558
542
  // nothing about the catalog changed. Skipping the no-op write keeps both the mtime
559
543
  // and `catalogWritten` honest; `added` still reports the routed rows the catalog
560
544
  // carries, because they are on disk either way.
561
- const onDiskBytes = currentCatalogFileContent(catalogPath);
562
- if (onDiskBytes !== null && onDiskBytes.equals(Buffer.from(content, "utf8"))) {
545
+ const preparedCatalog: PreparedCatalogFileWrite = { path: catalogPath, content };
546
+ if (!preparedBytesDifferFromDisk(preparedCatalog)) {
563
547
  return { added, path: catalogPath, catalogWritten: false, comboOmissions };
564
548
  }
565
549
 
566
- replaceActiveCodexCatalog(permit, owningCodexHome, {
567
- path: catalogPath,
568
- content,
569
- });
550
+ replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
570
551
  return {
571
552
  added,
572
553
  path: catalogPath,
@@ -620,7 +601,7 @@ export async function syncCatalogModels(
620
601
  comboOmissions,
621
602
  providerModelOutcomes,
622
603
  }),
623
- resolveCodexModelEntitlements(config),
604
+ resolveAdmittedCodexModelEntitlements(config),
624
605
  ]);
625
606
  const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
626
607
  // Desired state can flip OFF during the provider await above. The catalog
@@ -713,10 +694,25 @@ export function invalidateCodexModelsCacheWithPermit(
713
694
  client_version: "0.0.0",
714
695
  models: [...models, ...observedAccountModels],
715
696
  };
716
- replaceCodexModelsCache(permit, owningCodexHome, {
697
+ const preparedCache: PreparedCatalogFileWrite = {
717
698
  path: cachePath,
718
699
  content: `${JSON.stringify(wrapper, null, 2)}\n`,
719
- });
700
+ };
701
+ // The same no-op rule the active catalog already applies (#1459), for the same
702
+ // reason and at the second writer that has to obey it.
703
+ //
704
+ // This function is what `refreshCodexModelCatalog` reports as `cacheSynced`, and
705
+ // `handleStart` ORs that into the stale-app-server warning. Rewriting identical
706
+ // bytes bumped this file's mtime and returned `true`, so on a start where the
707
+ // catalog reproduced byte-identically — the settled case — the warning still
708
+ // claimed "Disk catalog/cache were updated" and told the operator their Codex
709
+ // model list might be stale, when nothing on disk had changed and Codex held the
710
+ // same model set the file already described. Returning `false` here makes
711
+ // `cacheSynced` mean what its name and its consumers already assume, and what
712
+ // `pullRemoteCatalog` and the early returns in `refreshCodexModelCatalog`
713
+ // already assert: a write happened.
714
+ if (!preparedBytesDifferFromDisk(preparedCache)) return false;
715
+ replaceCodexModelsCache(permit, owningCodexHome, preparedCache);
720
716
  return true;
721
717
  } catch {
722
718
  return false;
@@ -113,6 +113,15 @@ export interface GatherRoutedModelsOptions {
113
113
  providerModelOutcomes?: CatalogGatherProviderModelOutcome[];
114
114
  /** Internal convergence sink for the immutable policy that produced the returned rows. */
115
115
  discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[];
116
+ /**
117
+ * Each provider's cache content revision as of the moment its rows were chosen.
118
+ *
119
+ * A caller that derives something from these rows and wants to know later whether the rows are
120
+ * still current must use this, not a revision sampled after the gather returns: another flight
121
+ * can publish between the choice and the sample, and the derived value would then carry that
122
+ * flight's identity while holding these rows.
123
+ */
124
+ providerContentRevisions?: Map<string, string>;
116
125
  }
117
126
 
118
127
  interface GatherFlightResult {
@@ -121,6 +130,7 @@ interface GatherFlightResult {
121
130
  providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
122
131
  providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
123
132
  discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
133
+ providerContentRevisions: ReadonlyMap<string, string>;
124
134
  }
125
135
  interface GatherInflightEntry {
126
136
  readonly discoveryPolicyIdentity: string;
@@ -256,6 +266,7 @@ async function gatherRoutedModelsWithAuth(
256
266
  providerAuthOutcomes,
257
267
  providerModelOutcomes,
258
268
  discoveryPolicySnapshots,
269
+ providerContentRevisions,
259
270
  } = await entry.promise;
260
271
  if (options?.comboOmissions) {
261
272
  options.comboOmissions.length = 0;
@@ -269,6 +280,12 @@ async function gatherRoutedModelsWithAuth(
269
280
  options.providerModelOutcomes.length = 0;
270
281
  options.providerModelOutcomes.push(...providerModelOutcomes);
271
282
  }
283
+ if (options?.providerContentRevisions) {
284
+ options.providerContentRevisions.clear();
285
+ for (const [provider, revision] of providerContentRevisions) {
286
+ options.providerContentRevisions.set(provider, revision);
287
+ }
288
+ }
272
289
  if (options?.discoveryPolicySnapshots) {
273
290
  options.discoveryPolicySnapshots.length = 0;
274
291
  options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots);
@@ -763,6 +780,8 @@ async function gatherRoutedModelsUncached(
763
780
  providerAuthOutcomes: localProviderAuthOutcomes,
764
781
  providerModelOutcomes,
765
782
  discoveryPolicySnapshots: capture.discoveryPolicySnapshots,
783
+ // Stamped by each provider at the moment it chose its rows, not sampled here.
784
+ providerContentRevisions: new Map(providerResults.map(result => [result.outcome.provider, result.contentRevision])),
766
785
  };
767
786
  }
768
787
 
@@ -7,14 +7,17 @@ export const CONTEXT_BACKEND_PREFIX = "/backend-api/codex";
7
7
 
8
8
  /** The single definition of the opt-in, shared by injection and the runtime gate. */
9
9
  export function contextExperimentalEnabled(configContent: string): boolean {
10
- let parsed: { features?: { context_management?: { experimental_mode?: boolean } } };
10
+ let parsed: { features?: { context_management?: boolean | { experimental_mode?: boolean } } };
11
11
  try {
12
12
  parsed = Bun.TOML.parse(configContent) as typeof parsed;
13
13
  } catch {
14
14
  // Injection tolerates incomplete user config; malformed TOML is not an opt-in.
15
15
  return false;
16
16
  }
17
- return parsed.features?.context_management?.experimental_mode === true;
17
+ // Codex FeatureToml accepts either a boolean or the experimental_mode table.
18
+ // Keep strict true checks: malformed values must not activate a credentialed relay.
19
+ const setting = parsed.features?.context_management;
20
+ return setting === true || (typeof setting === "object" && setting?.experimental_mode === true);
18
21
  }
19
22
 
20
23
  let activation: { key: string; active: boolean } | undefined;
@@ -78,9 +78,9 @@ import {
78
78
  availableAccountGatedNativeModels,
79
79
  codexModelEntitlementStateForAccount,
80
80
  isCodexModelEntitlementSnapshotCurrent,
81
- resolveCodexModelEntitlements,
82
81
  type CodexModelEntitlementSnapshot,
83
82
  } from "./model-entitlements";
83
+ import { resolveAdmittedCodexModelEntitlements } from "./model-entitlement-admission";
84
84
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
85
85
  import { providerCodexAccountMode } from "../providers/registry";
86
86
  import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
@@ -434,7 +434,7 @@ export async function gatherCodexCatalogCandidate(
434
434
  providerModelOutcomes,
435
435
  discoveryPolicySnapshots: discoveryPolicies,
436
436
  }),
437
- resolveCodexModelEntitlements(snapshot.config),
437
+ resolveAdmittedCodexModelEntitlements(snapshot.config),
438
438
  ]);
439
439
  const processLocal = processEvidence(source);
440
440
  const sourceEvidence = sealCatalogGatherEvidenceSession(session);
@@ -260,7 +260,10 @@ export async function syncCodexOnStartIfEnabled(
260
260
  // The `.catch` is deliberate and stays: a failure to APPLY must not stop the
261
261
  // proxy from coming up. A failed sync simply reports no writes. The readiness
262
262
  // gate observes the real outcome so /readyz reflects the sync state exactly as
263
- // the PR contract defines (ready only on ok=true with no warning).
263
+ // the contract defines: ready on ok=true once the sync has settled. A nonempty
264
+ // `warning` is a local-Codex-artifact degradation the sync chose to continue
265
+ // past, and #5181 is what it cost to treat it as terminal — a healthy
266
+ // multi-provider proxy reported itself permanently unready.
264
267
  const outcome = readinessGate
265
268
  ? await runStartupReadinessSync(readinessGate, async () => (await sync(port)) ?? null)
266
269
  : await sync(port).catch(() => undefined);
@@ -23,7 +23,7 @@ import type {
23
23
  CodexHistoryWorkerOperation,
24
24
  HistoryWorkerResult,
25
25
  } from "./history-worker";
26
- import { currentHistoryDbBusyTimeoutMs, historyBackupPathFor } from "./history-provider";
26
+ import { currentHistoryDbBusyTimeoutMs, resolveExistingHistoryBackupPath } from "./history-provider";
27
27
  import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider";
28
28
  import { getCodexHome, resolveCodexStateDbPath } from "./paths";
29
29
 
@@ -32,7 +32,7 @@ import { getCodexHome, resolveCodexStateDbPath } from "./paths";
32
32
  *
33
33
  * The SQLite root can differ from CODEX_HOME and both environment/config inputs
34
34
  * can change between invocations. The parent resolves one exact target and hands
35
- * those canonical paths to the Worker rather than asking the Worker to infer a
35
+ * those resolved paths to the Worker rather than asking the Worker to infer a
36
36
  * possibly different environment.
37
37
  */
38
38
  export function resolveCodexHistoryJobTarget(): {
@@ -45,10 +45,10 @@ export function resolveCodexHistoryJobTarget(): {
45
45
  return {
46
46
  canonicalCodexHome: home,
47
47
  canonicalStateDbPath: stateDb,
48
- // Derived by the provider's own rule rather than guessed: the manifest lives
49
- // in the config directory under a hash of the state database, so a
50
- // hand-built path would address a different file entirely.
51
- canonicalBackupPath: historyBackupPathFor(stateDb),
48
+ // Resolve through the provider's canonical-first compatibility rule. Passing
49
+ // only the newly normalized name would hide a pre-#4442 Windows manifest from
50
+ // the Worker and make an upgrade look like an empty backup.
51
+ canonicalBackupPath: resolveExistingHistoryBackupPath(stateDb),
52
52
  };
53
53
  }
54
54
 
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { zstdDecompressSync } from "node:zlib";
4
+ import { parseSessionMetaLine, type ParsedSessionMeta } from "./history-rollout-read";
5
+ export { MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES, readThreadFieldsFromRollout, type RolloutThreadFields } from "./history-rollout-read";
5
6
  import { Database } from "bun:sqlite";
6
7
  import { resolveCodexStateDbPath } from "./paths";
7
8
  import { openCodexStateForPreflight } from "./history-state-open";
@@ -16,13 +17,6 @@ import {
16
17
  type CodexHistoryBackupManifest,
17
18
  } from "./history-manifest";
18
19
 
19
- /**
20
- * Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore.
21
- * Bounds peak memory while reconstructing thread rows; never write the decoded
22
- * JSONL to disk.
23
- */
24
- export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
25
-
26
20
  /**
27
21
  * The manifest that shadows one state database.
28
22
  *
@@ -341,6 +335,17 @@ class CodexHistoryIntegrityError extends Error {
341
335
  */
342
336
  export const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer";
343
337
 
338
+ /**
339
+ * The narrower reason: a provider-table transition found an `openai`-tagged row Codex has
340
+ * already paginated. It is not a plain stand-down, because the transition also takes the root
341
+ * `openai_base_url` out, and that combination would send the conversation to Codex's built-in
342
+ * OpenAI endpoint rather than this proxy.
343
+ *
344
+ * A constant for the same reason as the one above: `src/codex/inject/paginated-openai-compat.ts`
345
+ * decides what to do about it, and a literal repeated in two files is how the pair drifts apart.
346
+ */
347
+ export const HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE = "history_paginated_openai_requires_native_writer";
348
+
344
349
  function assertLegacyHistoryRecord(line: string): void {
345
350
  let value: unknown;
346
351
  try { value = JSON.parse(line); } catch { throw new CodexHistoryIntegrityError("history_rollout_record_invalid"); }
@@ -441,18 +446,32 @@ export function preflightCodexHistoryInjection(
441
446
  const paginatedColumn = columns.some(column => column.name === "history_mode");
442
447
  if (paginatedColumn && restoreEntries.length > 0) return HISTORY_RELABEL_STANDS_DOWN;
443
448
  for (const entry of restoreEntries) assertLegacyHistoryWritable(entry.rolloutPath);
444
- const rows = db.query<{ rollout_path: string; history_mode: string | null }, []>(`
445
- SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"}
449
+ const rows = db.query<{ rollout_path: string; history_mode: string | null; model_provider: string }, []>(`
450
+ SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"}, model_provider
446
451
  FROM threads
447
452
  WHERE ${providerTableMode
448
453
  ? resumeHistory ? "model_provider IN ('openai', 'opencodex')" : "0"
449
454
  : "model_provider = 'opencodex'"}
450
455
  `).all();
456
+ // No ORDER BY: a paginated opencodex row can precede a paginated openai one, so the
457
+ // verdict waits for the full scan instead of standing down on the first paginated row.
458
+ let foundPaginatedRow = false;
459
+ let foundPaginatedOpenaiRow = false;
451
460
  for (const row of rows) {
452
- if (paginatedColumn || row.history_mode === "paginated") return HISTORY_RELABEL_STANDS_DOWN;
461
+ if (paginatedColumn || row.history_mode === "paginated") {
462
+ foundPaginatedRow = true;
463
+ // A provider-table transition removes the root openai_base_url, and a row already
464
+ // paginated cannot be relabeled: standing it down would route the openai-tagged
465
+ // thread to Codex's built-in OpenAI endpoint. A still-legacy row only stands down.
466
+ if (providerTableMode && row.history_mode === "paginated" && row.model_provider === "openai") {
467
+ foundPaginatedOpenaiRow = true;
468
+ }
469
+ continue;
470
+ }
453
471
  assertLegacyHistoryWritable(row.rollout_path);
454
472
  }
455
- return null;
473
+ if (foundPaginatedOpenaiRow) return HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE;
474
+ return foundPaginatedRow ? HISTORY_RELABEL_STANDS_DOWN : null;
456
475
  } catch (error) {
457
476
  return error instanceof CodexHistoryIntegrityError
458
477
  ? error.message
@@ -1021,24 +1040,6 @@ function assertRestoreReadback(
1021
1040
  }
1022
1041
  }
1023
1042
 
1024
- interface ParsedSessionMeta {
1025
- record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record<string, unknown> };
1026
- }
1027
-
1028
- /** Parse one JSONL line into a `session_meta` record, or null if it isn't one. */
1029
- function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
1030
- let parsed: unknown;
1031
- try {
1032
- parsed = JSON.parse(line);
1033
- } catch {
1034
- return null;
1035
- }
1036
- if (!parsed || typeof parsed !== "object") return null;
1037
- const record = parsed as ParsedSessionMeta["record"];
1038
- if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return null;
1039
- return { record };
1040
- }
1041
-
1042
1043
  /**
1043
1044
  * Find the LAST `session_meta` line in a rollout, mirroring the app's last-writer-wins fold
1044
1045
  * (codex-rs `apply_session_meta_from_item`). We base our patch on the most recent metadata so we
@@ -1107,142 +1108,6 @@ function compensateConcurrentSessionMetaAppend(
1107
1108
  }
1108
1109
  }
1109
1110
 
1110
- /**
1111
- * Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a
1112
- * Phase-2 quarantine predates full `satellite-backup.json` thread snapshots.
1113
- *
1114
- * Uses the same last-writer-wins `session_meta` fold as {@link readLatestSessionMeta}, plus the
1115
- * first user-message preview (codex-rs `list.rs` / `EventMsg::UserMessage` path).
1116
- */
1117
- export interface RolloutThreadFields {
1118
- id: string;
1119
- modelProvider: string;
1120
- source: string;
1121
- firstUserMessage: string;
1122
- hasUserEvent: number;
1123
- cwd?: string;
1124
- historyMode?: string;
1125
- cliVersion?: string;
1126
- }
1127
-
1128
- function textFromContentParts(content: unknown): string | null {
1129
- if (typeof content === "string" && content.trim()) return content.trim();
1130
- if (!Array.isArray(content)) return null;
1131
- const parts: string[] = [];
1132
- for (const part of content) {
1133
- if (!part || typeof part !== "object") continue;
1134
- const p = part as Record<string, unknown>;
1135
- if (typeof p.text === "string" && p.text.trim()) parts.push(p.text.trim());
1136
- else if (typeof p.input_text === "string" && p.input_text.trim()) parts.push(p.input_text.trim());
1137
- }
1138
- const joined = parts.join("\n").trim();
1139
- return joined || null;
1140
- }
1141
-
1142
- /** Extract the first user-message preview from a rollout line, or null. */
1143
- function extractUserMessagePreview(line: string): string | null {
1144
- let parsed: unknown;
1145
- try {
1146
- parsed = JSON.parse(line);
1147
- } catch {
1148
- return null;
1149
- }
1150
- if (!parsed || typeof parsed !== "object") return null;
1151
- const record = parsed as { type?: unknown; payload?: unknown };
1152
- const payload = record.payload;
1153
- if (!payload || typeof payload !== "object") return null;
1154
- const p = payload as Record<string, unknown>;
1155
-
1156
- if (record.type === "event_msg") {
1157
- // codex-rs EventMsg::UserMessage — payload.type is "user_message" (or omitted in fixtures).
1158
- if (p.type === "user_message" || typeof p.message === "string") {
1159
- if (typeof p.message === "string" && p.message.trim()) return p.message.trim();
1160
- const fromContent = textFromContentParts(p.content);
1161
- if (fromContent) return fromContent;
1162
- }
1163
- return null;
1164
- }
1165
-
1166
- if (record.type === "response_item") {
1167
- if (p.type === "message" && p.role === "user") {
1168
- return textFromContentParts(p.content);
1169
- }
1170
- }
1171
- return null;
1172
- }
1173
-
1174
- /**
1175
- * Reconstruct thread identity + listing fields from a staged/restored rollout JSONL.
1176
- * Returns null when the file is missing or has no parseable `session_meta`.
1177
- *
1178
- * Accepts plain `.jsonl` or a lone `.jsonl.zst` (legacy Phase-2 quarantine). Compressed
1179
- * rollouts are decompressed in memory with {@link MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES};
1180
- * no decompressed copy is written to disk.
1181
- */
1182
- export function readThreadFieldsFromRollout(path: string): RolloutThreadFields | null {
1183
- if (!path || !existsSync(path)) return null;
1184
- let raw: string;
1185
- try {
1186
- raw = path.endsWith(".zst")
1187
- ? decompressRolloutZstUtf8(path)
1188
- : readFileSync(path, "utf8");
1189
- } catch {
1190
- return null;
1191
- }
1192
- return parseThreadFieldsFromRolloutText(raw);
1193
- }
1194
-
1195
- function decompressRolloutZstUtf8(
1196
- path: string,
1197
- maxBytes: number = MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES,
1198
- ): string {
1199
- const compressed = readFileSync(path);
1200
- const decoded = zstdDecompressSync(compressed as Uint8Array<ArrayBuffer>, {
1201
- maxOutputLength: maxBytes,
1202
- });
1203
- if (decoded.byteLength > maxBytes) {
1204
- throw new Error("rollout_zst_too_large");
1205
- }
1206
- return new TextDecoder().decode(decoded);
1207
- }
1208
-
1209
- function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | null {
1210
- const lines = raw.split("\n");
1211
- let latest: ParsedSessionMeta | null = null;
1212
- let firstUserMessage = "";
1213
- for (const line of lines) {
1214
- if (!line) continue;
1215
- if (line.includes("\"session_meta\"")) {
1216
- const meta = parseSessionMetaLine(line);
1217
- if (meta) latest = meta;
1218
- }
1219
- if (!firstUserMessage) {
1220
- const preview = extractUserMessagePreview(line);
1221
- if (preview) firstUserMessage = preview;
1222
- }
1223
- }
1224
- if (!latest) return null;
1225
- const payload = latest.record.payload;
1226
- const id = typeof payload.id === "string" ? payload.id : "";
1227
- if (!id) return null;
1228
- const modelProvider = typeof payload.model_provider === "string" && payload.model_provider
1229
- ? payload.model_provider
1230
- : "openai";
1231
- const source = typeof payload.source === "string" && payload.source
1232
- ? payload.source
1233
- : "cli";
1234
- return {
1235
- id,
1236
- modelProvider,
1237
- source,
1238
- firstUserMessage,
1239
- hasUserEvent: firstUserMessage.trim() ? 1 : 0,
1240
- ...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}),
1241
- ...(typeof payload.history_mode === "string" ? { historyMode: payload.history_mode } : {}),
1242
- ...(typeof payload.cli_version === "string" ? { cliVersion: payload.cli_version } : {}),
1243
- };
1244
- }
1245
-
1246
1111
  /**
1247
1112
  * Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line,
1248
1113
  * rather than rewriting line 1. The appended line clones the latest metadata payload (so no field