@iislee/opencodex 2.11.0 → 2.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (675) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +114 -19
  3. package/bin/ocx.mjs +164 -36
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-BF38heuV.js +104 -0
  6. package/gui/dist/assets/index-DMiI18Kv.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  9. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  10. package/gui/dist/provider-icons/claude-color.svg +1 -1
  11. package/gui/dist/provider-icons/cline-color.svg +16 -0
  12. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  13. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  14. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  15. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  16. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  17. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  18. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  19. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  20. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  21. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  22. package/gui/dist/provider-icons/grok.svg +1 -1
  23. package/gui/dist/provider-icons/groq-color.svg +1 -1
  24. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  25. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  26. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  27. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  28. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  29. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  30. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  31. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  32. package/gui/dist/provider-icons/openai.svg +1 -1
  33. package/gui/dist/provider-icons/opencode.svg +2 -1
  34. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  35. package/gui/dist/provider-icons/pi.svg +2 -2
  36. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  37. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  38. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  39. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  40. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  41. package/package.json +19 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +376 -52
  44. package/src/adapters/base.ts +54 -7
  45. package/src/adapters/client-fingerprint.ts +18 -12
  46. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  47. package/src/adapters/command-code.ts +601 -0
  48. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  49. package/src/adapters/cursor/cursor-errors.ts +129 -6
  50. package/src/adapters/cursor/discovery.ts +61 -4
  51. package/src/adapters/cursor/effort-map.ts +27 -3
  52. package/src/adapters/cursor/framing.ts +39 -0
  53. package/src/adapters/cursor/h2-pool.ts +123 -0
  54. package/src/adapters/cursor/http1-bidi.ts +361 -0
  55. package/src/adapters/cursor/images.ts +704 -0
  56. package/src/adapters/cursor/live-models.ts +174 -58
  57. package/src/adapters/cursor/live-transport.ts +609 -170
  58. package/src/adapters/cursor/native-exec-common.ts +23 -2
  59. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  60. package/src/adapters/cursor/native-exec-network.ts +1 -1
  61. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  62. package/src/adapters/cursor/native-exec.ts +101 -14
  63. package/src/adapters/cursor/protobuf-events.ts +829 -11
  64. package/src/adapters/cursor/protobuf-request.ts +383 -65
  65. package/src/adapters/cursor/request-builder.ts +237 -31
  66. package/src/adapters/cursor/tool-definitions.ts +242 -12
  67. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  68. package/src/adapters/cursor/transport.ts +22 -0
  69. package/src/adapters/cursor/types.ts +28 -1
  70. package/src/adapters/cursor.ts +133 -7
  71. package/src/adapters/google-antigravity-replay.ts +558 -34
  72. package/src/adapters/google-antigravity-wire.ts +43 -10
  73. package/src/adapters/google-http.ts +38 -10
  74. package/src/adapters/google-tool-schema.ts +80 -15
  75. package/src/adapters/google-truncation.ts +11 -0
  76. package/src/adapters/google.ts +618 -74
  77. package/src/adapters/identity.ts +39 -6
  78. package/src/adapters/kiro-errors.ts +11 -0
  79. package/src/adapters/kiro-events.ts +19 -1
  80. package/src/adapters/kiro-thinking.ts +18 -2
  81. package/src/adapters/kiro-tools.ts +10 -1
  82. package/src/adapters/kiro.ts +92 -54
  83. package/src/adapters/mimo-free.ts +17 -0
  84. package/src/adapters/openai-chat-url.ts +11 -0
  85. package/src/adapters/openai-chat.ts +1311 -336
  86. package/src/adapters/openai-responses-url.ts +16 -0
  87. package/src/adapters/openai-responses.ts +830 -56
  88. package/src/adapters/registry.ts +175 -0
  89. package/src/adapters/responses-tool-schema.ts +67 -0
  90. package/src/adapters/tool-call-id.ts +119 -0
  91. package/src/adapters/tool-catalog-nudge.ts +104 -21
  92. package/src/adapters/xai-web-search.ts +185 -0
  93. package/src/bridge.ts +428 -61
  94. package/src/chat/inbound.ts +43 -19
  95. package/src/chat/outbound.ts +82 -26
  96. package/src/claude/agents-inject.ts +32 -9
  97. package/src/claude/context-windows.ts +21 -5
  98. package/src/claude/desktop-3p.ts +243 -9
  99. package/src/claude/gateway-cache.ts +41 -4
  100. package/src/claude/inbound.ts +72 -3
  101. package/src/claude/model-info.ts +38 -15
  102. package/src/claude/outbound.ts +70 -16
  103. package/src/cli/account-api.ts +35 -1
  104. package/src/cli/account-auth.ts +33 -6
  105. package/src/cli/account-catalog-refresh.ts +14 -0
  106. package/src/cli/account-extended.ts +389 -2
  107. package/src/cli/account-main.ts +317 -0
  108. package/src/cli/account.ts +30 -6
  109. package/src/cli/agent.ts +45 -1
  110. package/src/cli/claude-agent-startup-sync.ts +73 -0
  111. package/src/cli/claude-desktop.ts +29 -4
  112. package/src/cli/claude.ts +103 -34
  113. package/src/cli/codex-log-guard-doctor.ts +103 -0
  114. package/src/cli/codex-shim-autorestore.ts +2 -0
  115. package/src/cli/codex-shim-readiness.ts +76 -0
  116. package/src/cli/combo.ts +8 -0
  117. package/src/cli/config-command.ts +74 -10
  118. package/src/cli/dispatch.ts +593 -0
  119. package/src/cli/doctor.ts +315 -43
  120. package/src/cli/ensure-desired-integrations.ts +152 -0
  121. package/src/cli/export-command.ts +46 -20
  122. package/src/cli/help.ts +20 -271
  123. package/src/cli/index.ts +378 -519
  124. package/src/cli/init.ts +4 -17
  125. package/src/cli/integrations.ts +120 -2
  126. package/src/cli/lab.ts +607 -0
  127. package/src/cli/launcher-context.ts +77 -0
  128. package/src/cli/minimax.ts +497 -0
  129. package/src/cli/models-runtime.ts +35 -2
  130. package/src/cli/models.ts +100 -14
  131. package/src/cli/observe.ts +92 -3
  132. package/src/cli/opencode.ts +4 -2
  133. package/src/cli/provider-runtime.ts +18 -1
  134. package/src/cli/provider.ts +24 -3
  135. package/src/cli/ready.ts +301 -0
  136. package/src/cli/registry.ts +437 -0
  137. package/src/cli/root.ts +86 -0
  138. package/src/cli/route-policy.ts +92 -0
  139. package/src/cli/runtime-api.ts +6 -3
  140. package/src/cli/star-prompt.ts +71 -15
  141. package/src/cli/status.ts +10 -3
  142. package/src/cli/system-restart-client.ts +146 -0
  143. package/src/cli/tray-proxy.ts +153 -6
  144. package/src/cli/v2.ts +105 -10
  145. package/src/cli.ts +1 -1
  146. package/src/clients/config-export.ts +1358 -21
  147. package/src/codex/account-label.ts +14 -1
  148. package/src/codex/account-lifecycle.ts +130 -13
  149. package/src/codex/account-namespaces.ts +49 -3
  150. package/src/codex/account-priority.ts +83 -0
  151. package/src/codex/account-store.ts +29 -2
  152. package/src/codex/account-usability.ts +25 -2
  153. package/src/codex/admission.ts +256 -0
  154. package/src/codex/affinity-debug.ts +162 -0
  155. package/src/codex/app-server-processes.ts +493 -106
  156. package/src/codex/app-server-restart-service.ts +232 -0
  157. package/src/codex/auth-api.ts +849 -242
  158. package/src/codex/auth-collision.ts +5 -3
  159. package/src/codex/auth-context.ts +345 -32
  160. package/src/codex/autostart-health.ts +8 -1
  161. package/src/codex/catalog/account-models.ts +67 -0
  162. package/src/codex/catalog/aggregation.ts +68 -10
  163. package/src/codex/catalog/bundled.ts +331 -33
  164. package/src/codex/catalog/effort.ts +121 -30
  165. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  166. package/src/codex/catalog/kinds.ts +2 -0
  167. package/src/codex/catalog/metadata.ts +529 -45
  168. package/src/codex/catalog/native-models.ts +72 -0
  169. package/src/codex/catalog/parsing.ts +224 -30
  170. package/src/codex/catalog/provider-fetch.ts +1460 -134
  171. package/src/codex/catalog/sync.ts +1449 -186
  172. package/src/codex/catalog-admission.ts +199 -0
  173. package/src/codex/catalog-refresh-status.ts +105 -0
  174. package/src/codex/catalog-write-serialization.ts +242 -0
  175. package/src/codex/catalog.ts +6 -3
  176. package/src/codex/codex-write-lock.ts +384 -0
  177. package/src/codex/convergence-types.ts +614 -0
  178. package/src/codex/convergence.ts +651 -0
  179. package/src/codex/coordinator-doctor.ts +332 -0
  180. package/src/codex/custom-model-catalog-migration.ts +176 -0
  181. package/src/codex/desired-state.ts +230 -0
  182. package/src/codex/features.ts +636 -39
  183. package/src/codex/generation.ts +202 -0
  184. package/src/codex/history-job.ts +407 -0
  185. package/src/codex/history-lock.ts +242 -0
  186. package/src/codex/history-migration-guardian.ts +26 -20
  187. package/src/codex/history-provider.ts +231 -28
  188. package/src/codex/history-transition.ts +105 -0
  189. package/src/codex/history-worker.ts +220 -0
  190. package/src/codex/inject-coordination.ts +290 -0
  191. package/src/codex/inject.ts +1073 -152
  192. package/src/codex/injected-marker.ts +37 -3
  193. package/src/codex/integration-record.ts +266 -0
  194. package/src/codex/internal/catalog-writer.ts +203 -0
  195. package/src/codex/internal/history-writer.ts +80 -0
  196. package/src/codex/journal.ts +66 -4
  197. package/src/codex/log-guard/inspect.ts +506 -0
  198. package/src/codex/log-guard/lock.ts +150 -0
  199. package/src/codex/log-guard/maintenance.ts +403 -0
  200. package/src/codex/log-guard/path-safety.ts +88 -0
  201. package/src/codex/log-guard/policy.ts +44 -0
  202. package/src/codex/log-guard/processes.ts +205 -0
  203. package/src/codex/log-guard/protection.ts +489 -0
  204. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  205. package/src/codex/main-account-cache.ts +24 -0
  206. package/src/codex/main-account.ts +29 -1
  207. package/src/codex/management-convergence.ts +167 -0
  208. package/src/codex/model-cache.ts +56 -10
  209. package/src/codex/model-entitlements.ts +353 -0
  210. package/src/codex/native-main-admission.ts +47 -0
  211. package/src/codex/native-main-auth-temp.ts +187 -0
  212. package/src/codex/native-main-claim.ts +178 -0
  213. package/src/codex/native-main-lock-file.ts +162 -0
  214. package/src/codex/native-main-owner.ts +329 -0
  215. package/src/codex/native-profile-api.ts +247 -0
  216. package/src/codex/native-profile-manager.ts +1531 -0
  217. package/src/codex/native-profile-processes.ts +121 -0
  218. package/src/codex/native-profile-recovery.ts +99 -0
  219. package/src/codex/native-profile-stage-store.ts +387 -0
  220. package/src/codex/native-profile-startup.ts +492 -0
  221. package/src/codex/native-profile-store.ts +855 -0
  222. package/src/codex/native-profile-types.ts +120 -0
  223. package/src/codex/native-residue.ts +682 -0
  224. package/src/codex/paths.ts +80 -1
  225. package/src/codex/plan-from-token.ts +140 -0
  226. package/src/codex/plan.ts +40 -0
  227. package/src/codex/plugins-doctor.ts +1 -1
  228. package/src/codex/pool-rotation.ts +74 -4
  229. package/src/codex/project-config-warnings.ts +20 -6
  230. package/src/codex/prompt-journal.ts +352 -0
  231. package/src/codex/prompt-layers.ts +967 -0
  232. package/src/codex/prompt-lock.ts +143 -0
  233. package/src/codex/quota-rejection.ts +298 -0
  234. package/src/codex/quota.ts +175 -13
  235. package/src/codex/refresh.ts +11 -2
  236. package/src/codex/reset-credit-recovery.ts +1044 -0
  237. package/src/codex/routing.ts +505 -94
  238. package/src/codex/runtime.ts +159 -38
  239. package/src/codex/shim.ts +1009 -28
  240. package/src/codex/subagent-model-fallback.ts +350 -35
  241. package/src/codex/sync.ts +191 -2
  242. package/src/codex/transition-state.ts +612 -0
  243. package/src/codex/upstream-host-health.ts +368 -0
  244. package/src/codex/user-identity.ts +557 -0
  245. package/src/codex/warmup.ts +187 -81
  246. package/src/codex/write-coordination.ts +114 -0
  247. package/src/combos/failover.ts +20 -0
  248. package/src/combos/index.ts +4 -0
  249. package/src/combos/request.ts +32 -0
  250. package/src/combos/types.ts +81 -9
  251. package/src/config/provider-name.ts +24 -0
  252. package/src/config.ts +1762 -140
  253. package/src/generated/compatibility-version.json +3164 -0
  254. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  255. package/src/grok/inject.ts +16 -5
  256. package/src/grok/inspect.ts +45 -0
  257. package/src/grok/sync.ts +2 -2
  258. package/src/images/loop.ts +152 -29
  259. package/src/images/plan.ts +23 -13
  260. package/src/integrations/config-io.ts +269 -0
  261. package/src/integrations/journal.ts +315 -0
  262. package/src/integrations/merge.ts +135 -0
  263. package/src/integrations/mutation-flight.ts +71 -0
  264. package/src/integrations/native/ownership-preflight.ts +202 -0
  265. package/src/integrations/omp-yaml-source.ts +358 -0
  266. package/src/integrations/owned-refresh.ts +74 -0
  267. package/src/integrations/ownership.ts +111 -0
  268. package/src/integrations/registry.ts +159 -0
  269. package/src/integrations/serialize.ts +314 -0
  270. package/src/integrations/state.ts +361 -0
  271. package/src/integrations/store.ts +103 -0
  272. package/src/integrations/writer-lock.ts +98 -0
  273. package/src/integrations/writer.ts +691 -0
  274. package/src/lab/artifacts/sanitize.ts +586 -0
  275. package/src/lab/artifacts/secure-fs.ts +475 -0
  276. package/src/lab/artifacts/store.ts +310 -0
  277. package/src/lab/automation/budgets.ts +78 -0
  278. package/src/lab/automation/config-persistence.ts +256 -0
  279. package/src/lab/automation/constants.ts +39 -0
  280. package/src/lab/automation/cooldown.ts +103 -0
  281. package/src/lab/automation/dispatch.ts +211 -0
  282. package/src/lab/automation/index.ts +13 -0
  283. package/src/lab/automation/orchestrator.ts +499 -0
  284. package/src/lab/automation/persistence.ts +512 -0
  285. package/src/lab/automation/planner.ts +371 -0
  286. package/src/lab/automation/policy.ts +136 -0
  287. package/src/lab/automation/queue.ts +191 -0
  288. package/src/lab/automation/recovery.ts +24 -0
  289. package/src/lab/automation/route-context.ts +21 -0
  290. package/src/lab/automation/run-key.ts +44 -0
  291. package/src/lab/automation/runs-query.ts +34 -0
  292. package/src/lab/automation/types.ts +160 -0
  293. package/src/lab/conformance/assertion.ts +325 -0
  294. package/src/lab/conformance/digest.ts +22 -0
  295. package/src/lab/conformance/executor.ts +741 -0
  296. package/src/lab/conformance/fixture-provider.ts +27 -0
  297. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  298. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  299. package/src/lab/conformance/harness-budget.ts +47 -0
  300. package/src/lab/conformance/index.ts +5 -0
  301. package/src/lab/conformance/jcs.ts +64 -0
  302. package/src/lab/conformance/json-pointer.ts +39 -0
  303. package/src/lab/conformance/manifest.ts +180 -0
  304. package/src/lab/conformance/mcp-stub.ts +179 -0
  305. package/src/lab/conformance/negative-controls.ts +164 -0
  306. package/src/lab/conformance/observation.ts +355 -0
  307. package/src/lab/conformance/runner.ts +68 -0
  308. package/src/lab/conformance/sse-normalize.ts +59 -0
  309. package/src/lab/conformance/suite-manifest.ts +78 -0
  310. package/src/lab/conformance/types.ts +214 -0
  311. package/src/lab/constants.ts +126 -0
  312. package/src/lab/digest.ts +64 -0
  313. package/src/lab/events/errors.ts +9 -0
  314. package/src/lab/events/limits.ts +117 -0
  315. package/src/lab/events/types.ts +229 -0
  316. package/src/lab/events/validate.ts +781 -0
  317. package/src/lab/fabric/constants.ts +40 -0
  318. package/src/lab/fabric/executor.ts +492 -0
  319. package/src/lab/fabric/index.ts +80 -0
  320. package/src/lab/fabric/manifest.ts +222 -0
  321. package/src/lab/fabric/observe.ts +489 -0
  322. package/src/lab/fabric/patch.ts +79 -0
  323. package/src/lab/fabric/producer-child.ts +139 -0
  324. package/src/lab/fabric/producer-isolate.ts +276 -0
  325. package/src/lab/fabric/producer-protocol.ts +61 -0
  326. package/src/lab/fabric/scratch.ts +439 -0
  327. package/src/lab/fabric/subject.ts +106 -0
  328. package/src/lab/fabric/types.ts +134 -0
  329. package/src/lab/fabric/verifier.ts +98 -0
  330. package/src/lab/index.ts +54 -0
  331. package/src/lab/ledger/artifact-refs.ts +127 -0
  332. package/src/lab/ledger/invalidation.ts +136 -0
  333. package/src/lab/ledger/purge.ts +310 -0
  334. package/src/lab/ledger/store.ts +532 -0
  335. package/src/lab/live/credential-lease.ts +53 -0
  336. package/src/lab/live/destination.ts +155 -0
  337. package/src/lab/live/executor.ts +336 -0
  338. package/src/lab/live/inert-tools.ts +56 -0
  339. package/src/lab/live/manifest.ts +85 -0
  340. package/src/lab/live/mcp-loopback.ts +57 -0
  341. package/src/lab/live/runner.ts +19 -0
  342. package/src/lab/live/sandbox.ts +61 -0
  343. package/src/lab/live/suite-manifest.ts +41 -0
  344. package/src/lab/live/transport.ts +118 -0
  345. package/src/lab/live/types.ts +197 -0
  346. package/src/lab/observe/from-conformance.ts +301 -0
  347. package/src/lab/observe/from-live.ts +117 -0
  348. package/src/lab/paths.ts +153 -0
  349. package/src/lab/projection/rebuild.ts +495 -0
  350. package/src/lab/projection/schema.ts +135 -0
  351. package/src/lab/projection/verdicts.ts +474 -0
  352. package/src/lab/projection/verification.ts +412 -0
  353. package/src/lab/public/bundle.ts +217 -0
  354. package/src/lab/public/community-authority.ts +175 -0
  355. package/src/lab/public/community-files.ts +29 -0
  356. package/src/lab/public/community.ts +479 -0
  357. package/src/lab/public/file-safety.ts +155 -0
  358. package/src/lab/public/ids.ts +26 -0
  359. package/src/lab/public/index.ts +16 -0
  360. package/src/lab/public/mutation-lock.ts +424 -0
  361. package/src/lab/public/operator.ts +353 -0
  362. package/src/lab/public/origin-purge.ts +79 -0
  363. package/src/lab/public/origin.ts +203 -0
  364. package/src/lab/public/privacy.ts +143 -0
  365. package/src/lab/public/private-file.ts +261 -0
  366. package/src/lab/public/project.ts +124 -0
  367. package/src/lab/public/purge-test-fault.ts +21 -0
  368. package/src/lab/public/purge.ts +223 -0
  369. package/src/lab/public/registry.ts +44 -0
  370. package/src/lab/public/revocation.ts +252 -0
  371. package/src/lab/public/signature.ts +243 -0
  372. package/src/lab/public/storage.ts +105 -0
  373. package/src/lab/public/strict-json.ts +206 -0
  374. package/src/lab/public/time.ts +26 -0
  375. package/src/lab/public/types.ts +172 -0
  376. package/src/lab/public/validate.ts +391 -0
  377. package/src/lab/query/catalog.ts +101 -0
  378. package/src/lab/query/connection.ts +107 -0
  379. package/src/lab/query/constants.ts +4 -0
  380. package/src/lab/query/cursor.ts +132 -0
  381. package/src/lab/query/dto-map.ts +277 -0
  382. package/src/lab/query/errors.ts +22 -0
  383. package/src/lab/query/freshness.ts +53 -0
  384. package/src/lab/query/index.ts +45 -0
  385. package/src/lab/query/latest-observation.ts +59 -0
  386. package/src/lab/query/passive-production.ts +159 -0
  387. package/src/lab/query/queries.ts +444 -0
  388. package/src/lab/query/types.ts +266 -0
  389. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  390. package/src/lab/subject/installation-salt.ts +112 -0
  391. package/src/lab/subject/protocol-subject.ts +80 -0
  392. package/src/lab/subject/route-subject.ts +74 -0
  393. package/src/lib/app-owned-memory-stores.ts +22 -0
  394. package/src/lib/bounded-body.ts +153 -9
  395. package/src/lib/bun-runtime.ts +125 -12
  396. package/src/lib/bun-stream-caps.ts +13 -9
  397. package/src/lib/codex-restart-contract.ts +120 -0
  398. package/src/lib/config-ownership.ts +6 -2
  399. package/src/lib/destination-policy.ts +65 -1
  400. package/src/lib/errors.ts +44 -2
  401. package/src/lib/fabric-task-execution-authority.ts +7 -0
  402. package/src/lib/fabric-task-host.ts +29 -0
  403. package/src/lib/lab-activation.ts +223 -0
  404. package/src/lib/lab-live-execution-authority.ts +13 -0
  405. package/src/lib/lab-live-host.ts +30 -0
  406. package/src/lib/lab-live-pinned-sender.ts +56 -0
  407. package/src/lib/lab-live-route-production.ts +130 -0
  408. package/src/lib/lab-passive-linker-registration.ts +26 -0
  409. package/src/lib/local-management-attestation.ts +51 -0
  410. package/src/lib/local-management-capability.ts +100 -0
  411. package/src/lib/local-provider-reload-contract.ts +100 -0
  412. package/src/lib/optional-shutdown-hooks.ts +57 -0
  413. package/src/lib/pinned-http.ts +145 -26
  414. package/src/lib/process-control.ts +4 -1
  415. package/src/lib/provider-outbound.ts +49 -9
  416. package/src/lib/redact.ts +419 -3
  417. package/src/lib/self-launch-argv.ts +15 -0
  418. package/src/lib/server-resource-ownership.ts +71 -0
  419. package/src/lib/shadow-call.ts +35 -4
  420. package/src/lib/sse-decoder.ts +41 -0
  421. package/src/lib/state-store-registrations.ts +10 -2
  422. package/src/lib/system-restart-contract.ts +73 -0
  423. package/src/lib/token-estimate.ts +19 -2
  424. package/src/lib/tool-argument-integers.ts +202 -0
  425. package/src/lib/translator-budget.ts +44 -0
  426. package/src/lib/upstream-http-version.ts +57 -0
  427. package/src/lib/upstream-reachability.ts +95 -0
  428. package/src/lib/upstream-retry.ts +156 -3
  429. package/src/lib/windows-atomic-replace.ts +155 -0
  430. package/src/lib/windows-elevation.ts +70 -2
  431. package/src/lib/windows-secret-acl.ts +409 -69
  432. package/src/lib/windows-service-wrappers.ts +72 -0
  433. package/src/lib/windows-text.ts +106 -0
  434. package/src/lib/windows-user-principal.ts +341 -0
  435. package/src/lib/winsw.ts +33 -5
  436. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  437. package/src/oauth/account-import/index.ts +15 -0
  438. package/src/oauth/account-import/parser.ts +83 -0
  439. package/src/oauth/account-import/registry.ts +18 -0
  440. package/src/oauth/account-import/service.ts +75 -0
  441. package/src/oauth/account-import/types.ts +91 -0
  442. package/src/oauth/anthropic.ts +12 -1
  443. package/src/oauth/callback-server.ts +8 -2
  444. package/src/oauth/chatgpt.ts +12 -1
  445. package/src/oauth/command-code.ts +239 -0
  446. package/src/oauth/cursor.ts +46 -5
  447. package/src/oauth/google-antigravity.ts +35 -3
  448. package/src/oauth/health.ts +20 -12
  449. package/src/oauth/index.ts +398 -66
  450. package/src/oauth/key-providers.ts +16 -0
  451. package/src/oauth/kimi.ts +16 -2
  452. package/src/oauth/kiro.ts +50 -6
  453. package/src/oauth/local-token-detect.ts +11 -2
  454. package/src/oauth/log.ts +3 -1
  455. package/src/oauth/login-cli.ts +88 -28
  456. package/src/oauth/nous.ts +798 -0
  457. package/src/oauth/store.ts +119 -21
  458. package/src/oauth/token-guardian.ts +9 -3
  459. package/src/pi/models.ts +2 -2
  460. package/src/providers/alibaba-region-migration.ts +1 -1
  461. package/src/providers/antigravity-models.ts +521 -31
  462. package/src/providers/base-url-choices.ts +10 -0
  463. package/src/providers/codex-capacity.ts +292 -0
  464. package/src/providers/command-code-efforts.ts +144 -0
  465. package/src/providers/context-cap.ts +22 -5
  466. package/src/providers/cursor-pool.ts +72 -0
  467. package/src/providers/derive.ts +253 -6
  468. package/src/providers/fastwire.ts +501 -0
  469. package/src/providers/free-directory.ts +10 -7
  470. package/src/providers/google-vertex-location.ts +14 -0
  471. package/src/providers/key-failover.ts +71 -3
  472. package/src/providers/label.ts +1 -1
  473. package/src/providers/model-discovery-limits.ts +16 -0
  474. package/src/providers/model-discovery.ts +115 -22
  475. package/src/providers/model-rename-migration.ts +255 -0
  476. package/src/providers/model-rename-startup.ts +28 -0
  477. package/src/providers/openai-sidecar.ts +72 -4
  478. package/src/providers/openai-tier-startup.ts +31 -2
  479. package/src/providers/openai-tiers.ts +119 -4
  480. package/src/providers/openai-virtual-models.ts +1 -0
  481. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  482. package/src/providers/provider-id-rewrite.ts +29 -0
  483. package/src/providers/quota.ts +1319 -38
  484. package/src/providers/registry.ts +1429 -111
  485. package/src/providers/request-pacing.ts +310 -0
  486. package/src/providers/service-tier.ts +277 -0
  487. package/src/providers/slug-codec.ts +42 -6
  488. package/src/providers/static-model-discovery.ts +86 -0
  489. package/src/providers/xai-responses-opt-in.ts +15 -0
  490. package/src/providers/xai-transport.ts +11 -4
  491. package/src/reasoning-effort.ts +49 -1
  492. package/src/responses/compaction.ts +26 -1
  493. package/src/responses/custom-tool-compat.ts +266 -0
  494. package/src/responses/hosted-tool-policy.ts +9 -0
  495. package/src/responses/namespace-tool-compat.ts +355 -0
  496. package/src/responses/parser.ts +220 -38
  497. package/src/responses/provider-continuation.ts +98 -0
  498. package/src/responses/provider-opaque-metadata.ts +73 -0
  499. package/src/responses/reasoning-envelope.ts +9 -1
  500. package/src/responses/reasoning-replay-cache.ts +426 -0
  501. package/src/responses/schema.ts +7 -1
  502. package/src/responses/spill-store.ts +75 -10
  503. package/src/responses/state.ts +565 -27
  504. package/src/responses/thought-signature-replay.ts +347 -0
  505. package/src/responses/tool-search-compat.ts +301 -0
  506. package/src/responses/truncated-stop-reason.ts +60 -0
  507. package/src/router.ts +366 -30
  508. package/src/routing/analytics.ts +378 -0
  509. package/src/routing/capability.ts +244 -0
  510. package/src/routing/compatibility/assemble.ts +73 -0
  511. package/src/routing/compatibility/behavior.ts +278 -0
  512. package/src/routing/compatibility/catalog.ts +99 -0
  513. package/src/routing/compatibility/endpoint.ts +52 -0
  514. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  515. package/src/routing/compatibility/policy.ts +181 -0
  516. package/src/routing/compatibility/provider-slot.ts +56 -0
  517. package/src/routing/compatibility/reader.ts +110 -0
  518. package/src/routing/compatibility/subject.ts +191 -0
  519. package/src/routing/compatibility/types.ts +64 -0
  520. package/src/routing/compatibility/version.ts +104 -0
  521. package/src/routing/cost.ts +77 -0
  522. package/src/routing/evaluator.ts +495 -0
  523. package/src/routing/health.ts +412 -0
  524. package/src/routing/history/cursor.ts +43 -0
  525. package/src/routing/history/indexer.ts +605 -0
  526. package/src/routing/history/schema.ts +72 -0
  527. package/src/routing/profile-namespace.ts +15 -0
  528. package/src/routing/profile.ts +547 -0
  529. package/src/routing/quota.ts +145 -0
  530. package/src/routing/request-evidence.ts +45 -0
  531. package/src/routing/trace.ts +776 -0
  532. package/src/server/adapter-resolve.ts +2 -29
  533. package/src/server/auth-cors.ts +267 -46
  534. package/src/server/background-lifecycle.ts +182 -0
  535. package/src/server/chat-completions.ts +130 -56
  536. package/src/server/chat-native-sse.ts +331 -0
  537. package/src/server/chat-native.ts +426 -0
  538. package/src/server/claude-messages.ts +159 -43
  539. package/src/server/direct-local-http.ts +347 -0
  540. package/src/server/effort-policy.ts +18 -0
  541. package/src/server/github-copilot-responses-repair.ts +338 -0
  542. package/src/server/gui-static.ts +39 -10
  543. package/src/server/images.ts +94 -12
  544. package/src/server/index.ts +865 -181
  545. package/src/server/lifecycle.ts +284 -13
  546. package/src/server/live.ts +136 -17
  547. package/src/server/local-management-read-client.ts +90 -0
  548. package/src/server/local-provider-reload-client.ts +137 -0
  549. package/src/server/management/agent-settings-routes.ts +398 -103
  550. package/src/server/management/api-key-usage.ts +31 -5
  551. package/src/server/management/body.ts +6 -0
  552. package/src/server/management/combo-routes.ts +62 -24
  553. package/src/server/management/config-routes.ts +464 -51
  554. package/src/server/management/context.ts +80 -2
  555. package/src/server/management/integration-routes.ts +498 -0
  556. package/src/server/management/lab-automation-routes.ts +206 -0
  557. package/src/server/management/lab-routes.ts +563 -0
  558. package/src/server/management/logs-usage-routes.ts +101 -32
  559. package/src/server/management/model-routes.ts +189 -131
  560. package/src/server/management/model-rows.ts +163 -0
  561. package/src/server/management/native-integration-routes.ts +769 -0
  562. package/src/server/management/oauth-account-routes.ts +80 -4
  563. package/src/server/management/provider-capability-config.ts +48 -0
  564. package/src/server/management/provider-routes.ts +764 -157
  565. package/src/server/management/request-history-routes.ts +191 -0
  566. package/src/server/management/routing-analytics-routes.ts +74 -0
  567. package/src/server/management/routing-profile-routes.ts +380 -0
  568. package/src/server/management/shared.ts +27 -11
  569. package/src/server/management/sidebar-routes.ts +47 -31
  570. package/src/server/management/storage-log-guard-routes.ts +186 -0
  571. package/src/server/management/sync-response.ts +69 -0
  572. package/src/server/management/system-restart.ts +288 -32
  573. package/src/server/management/system-routes.ts +77 -0
  574. package/src/server/management/usage-summary-cache.ts +9 -1
  575. package/src/server/management/vision-sidecar-options.ts +167 -0
  576. package/src/server/management/web-search-sidecar-options.ts +120 -0
  577. package/src/server/management-api.ts +115 -14
  578. package/src/server/management-auth.ts +220 -5
  579. package/src/server/passive-route-linker.ts +66 -0
  580. package/src/server/ports.ts +41 -1
  581. package/src/server/proxy-liveness.ts +132 -5
  582. package/src/server/readiness.ts +99 -0
  583. package/src/server/relay-eager.ts +82 -42
  584. package/src/server/relay.ts +236 -76
  585. package/src/server/request-decompress.ts +113 -6
  586. package/src/server/request-log.ts +235 -22
  587. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  588. package/src/server/responses/agent-task-recovery.ts +465 -0
  589. package/src/server/responses/collaboration.ts +204 -35
  590. package/src/server/responses/compact.ts +442 -55
  591. package/src/server/responses/core.ts +2872 -331
  592. package/src/server/responses/empty-completion-guard.ts +276 -0
  593. package/src/server/responses/encrypted-payload.ts +62 -39
  594. package/src/server/responses/fetch-helpers.ts +79 -4
  595. package/src/server/responses/input-admission.ts +185 -0
  596. package/src/server/responses/pacing-overload.ts +13 -0
  597. package/src/server/responses/policy-fallback.ts +178 -0
  598. package/src/server/responses/responses-field-backfill.ts +251 -0
  599. package/src/server/responses/terminal-guard.ts +26 -5
  600. package/src/server/responses/upstream-error.ts +5 -0
  601. package/src/server/responses/ws-upstream.ts +308 -0
  602. package/src/server/responses-custom-tool-repair.ts +282 -0
  603. package/src/server/responses-item-id-repair.ts +54 -6
  604. package/src/server/responses-json-events.ts +90 -0
  605. package/src/server/responses-model-rewrite.ts +29 -0
  606. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  607. package/src/server/responses-snapshot-repair.ts +621 -0
  608. package/src/server/responses-terminal-repair.ts +342 -0
  609. package/src/server/responses-tool-search-repair.ts +267 -0
  610. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  611. package/src/server/responses.ts +18 -2
  612. package/src/server/search.ts +78 -13
  613. package/src/server/sse-frame-buffer.ts +292 -0
  614. package/src/server/sse-payload-rewrite.ts +110 -22
  615. package/src/server/startup-action-control.ts +8 -1
  616. package/src/server/startup-health-cache.ts +19 -1
  617. package/src/server/system-env.ts +80 -9
  618. package/src/server/ws-bridge.ts +39 -38
  619. package/src/service-manager-probe.ts +892 -0
  620. package/src/service.ts +1111 -90
  621. package/src/sidecar/auth.ts +92 -0
  622. package/src/sidecar/candidates.ts +83 -0
  623. package/src/storage/cleanup.ts +2 -2
  624. package/src/storage/scanner.ts +1 -1
  625. package/src/storage/worker-lifecycle.ts +14 -14
  626. package/src/tray/windows-tray.ps1 +83 -9
  627. package/src/tray/windows.ts +43 -16
  628. package/src/types/accounts.ts +37 -0
  629. package/src/types/config.ts +845 -0
  630. package/src/types/provider.ts +545 -0
  631. package/src/types/request.ts +384 -0
  632. package/src/types/tools.ts +131 -0
  633. package/src/types/wire.ts +80 -0
  634. package/src/types.ts +104 -1236
  635. package/src/update/index.ts +32 -19
  636. package/src/update/job.ts +442 -67
  637. package/src/update/notify.ts +12 -6
  638. package/src/update/npm-cache-preflight.d.mts +47 -0
  639. package/src/update/npm-cache-preflight.mjs +201 -0
  640. package/src/update/transactional-install.d.mts +22 -0
  641. package/src/update/transactional-install.mjs +259 -0
  642. package/src/usage/cost.ts +0 -0
  643. package/src/usage/expected-prices.ts +268 -16
  644. package/src/usage/log.ts +606 -41
  645. package/src/usage/summary.ts +177 -9
  646. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  647. package/src/usage/user-cost-overlays.ts +314 -0
  648. package/src/vision/anthropic-describe.ts +10 -6
  649. package/src/vision/backends.ts +97 -0
  650. package/src/vision/describe.ts +9 -3
  651. package/src/vision/eligibility.ts +250 -0
  652. package/src/vision/index.ts +238 -24
  653. package/src/vision/reasoning.ts +55 -0
  654. package/src/vision/routed-describe.ts +175 -0
  655. package/src/vision/timeout-bounds.ts +9 -0
  656. package/src/web-search/anthropic-executor.ts +13 -7
  657. package/src/web-search/backends.ts +108 -0
  658. package/src/web-search/exa-executor.ts +88 -0
  659. package/src/web-search/executor.ts +11 -3
  660. package/src/web-search/gemini-executor.ts +141 -0
  661. package/src/web-search/index.ts +150 -15
  662. package/src/web-search/loop.ts +279 -50
  663. package/src/web-search/parse.ts +125 -30
  664. package/src/web-search/sources.ts +60 -0
  665. package/src/web-search/xai-executor.ts +219 -0
  666. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  667. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  668. package/gui/dist/provider-icons/antigravity.svg +0 -1
  669. package/gui/dist/provider-icons/claude.svg +0 -1
  670. package/gui/dist/provider-icons/copilot.svg +0 -1
  671. package/gui/dist/provider-icons/cursor.svg +0 -2
  672. package/gui/dist/provider-icons/gemini.svg +0 -1
  673. package/gui/dist/provider-icons/grok-color.svg +0 -1
  674. package/gui/dist/provider-icons/kiro.svg +0 -14
  675. package/src/cli/internal-dispatch.ts +0 -20
@@ -1,8 +1,51 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
- import { atomicWriteFile, loadConfig, subagentDefaultSyncEffective, websocketsEnabled } from "../config";
3
- import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal";
4
- import { restoreCodexCatalog } from "./catalog";
5
- import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider";
2
+ import {
3
+ atomicWriteFile,
4
+ loadConfig,
5
+ observeConfigGeneration,
6
+ readConfigAdmissionSnapshot,
7
+ subagentDefaultSyncEffective,
8
+ websocketsEnabled,
9
+ } from "../config";
10
+ import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock";
11
+ import { shouldSyncCodexOnStart } from "./desired-state";
12
+ import { resolveCodexHistoryTransition } from "./history-transition";
13
+ import {
14
+ buildInjectWitness,
15
+ captureCodexPreImages,
16
+ codexInjectLockOutcome,
17
+ codexWriteCoordinationEligibility,
18
+ CodexPartialWriteError,
19
+ CodexWriteConflictError,
20
+ DEFAULT_INJECT_LOCK_TIMEOUT_MS,
21
+ recomputeInjectWitness,
22
+ restoreCodexPreImages,
23
+ } from "./inject-coordination";
24
+ import { readIntegrationRecord } from "./integration-record";
25
+ import { classifyNativeRoutedResidue } from "./native-residue";
26
+ import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight";
27
+ import {
28
+ resolveCodexCoordinatorDatabasePath,
29
+ resolveEffectiveUserIdentity,
30
+ } from "./user-identity";
31
+ import {
32
+ markJournalInjectedState,
33
+ journaledInjectedOpenaiBaseUrl,
34
+ journaledInjectedCatalogPath,
35
+ removeJournal,
36
+ restoreJournalState,
37
+ writeJournal,
38
+ } from "./journal";
39
+ import { withCatalogWriteSerialization } from "./catalog-write-serialization";
40
+ import { restoreCodexCatalogWithPermit } from "./catalog/sync";
41
+ import { syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider";
42
+ import {
43
+ describeHistoryJobFailure,
44
+ deriveCodexHistoryOperation,
45
+ resolveCodexHistoryJobTarget,
46
+ runCodexHistoryJob,
47
+ type CodexHistoryJobOutcome,
48
+ } from "./history-job";
6
49
  import {
7
50
  OCX_SECTION_MARKER,
8
51
  hasInjectedCodexRouting,
@@ -11,9 +54,19 @@ import {
11
54
  providerTableStart,
12
55
  providerTableString,
13
56
  rootTomlString,
57
+ stripJournaledOpenaiBaseUrl,
14
58
  tomlStringPattern,
15
59
  } from "./injected-marker";
16
- import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths";
60
+ import {
61
+ CODEX_CONFIG_PATH,
62
+ CODEX_PROFILE_PATH,
63
+ DEFAULT_CATALOG_PATH,
64
+ getCodexHome,
65
+ parseTomlString,
66
+ readRootTomlString,
67
+ resolveCodexConfigPath,
68
+ tomlString,
69
+ } from "./paths";
17
70
  import { resolveEffectiveProjectModelProvider } from "./project-config-warnings";
18
71
  import {
19
72
  transformManagedSubagentDefaults,
@@ -27,7 +80,9 @@ export { hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl };
27
80
 
28
81
  export function externalCodexModelProvider(content: string): string | null {
29
82
  const provider = resolveEffectiveProjectModelProvider(content).provider;
30
- return provider && provider !== "openai" && provider !== "opencodex" ? provider : null;
83
+ return provider && provider !== "openai" && provider !== "opencodex"
84
+ ? provider
85
+ : null;
31
86
  }
32
87
 
33
88
  export function currentExternalCodexModelProvider(): string | null {
@@ -70,15 +125,35 @@ export interface InjectCodexOptions {
70
125
  * failing on a missing model_catalog_json file.
71
126
  */
72
127
  catalogPath?: string | null;
128
+ /**
129
+ * How long to wait for the Codex write lock before reporting contention.
130
+ *
131
+ * Bounded by default so a stuck holder cannot wedge `ocx start`; an explicit
132
+ * caller that is willing to wait can raise it.
133
+ */
134
+ lockTimeoutMs?: number;
135
+ /**
136
+ * Validate the same config transformations and write-coordination eligibility without
137
+ * changing the journal, config, profile, catalog, cache, or history. Sync uses this before
138
+ * provider discovery so a deterministic config refusal cannot degrade an existing catalog.
139
+ */
140
+ validateOnly?: boolean;
73
141
  }
74
142
 
75
143
  function configuredManagedSubagentDefaults(
76
- config: Pick<OcxConfig, "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"> | undefined,
144
+ config:
145
+ | Pick<
146
+ OcxConfig,
147
+ "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"
148
+ >
149
+ | undefined,
77
150
  ): ManagedSubagentDefaults | null {
78
151
  if (!subagentDefaultSyncEffective(config ?? {})) return null;
79
152
  return {
80
153
  model: config!.injectionModel!.trim(),
81
- ...(config!.injectionEffort?.trim() ? { reasoningEffort: config!.injectionEffort.trim() } : {}),
154
+ ...(config!.injectionEffort?.trim()
155
+ ? { reasoningEffort: config!.injectionEffort.trim() }
156
+ : {}),
82
157
  };
83
158
  }
84
159
 
@@ -98,7 +173,13 @@ function configuredManagedSubagentDefaults(
98
173
  */
99
174
  export function isLoopbackHostname(hostname: string | undefined): boolean {
100
175
  const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
101
- return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
176
+ return (
177
+ normalized === "" ||
178
+ normalized === "localhost" ||
179
+ normalized === "127.0.0.1" ||
180
+ normalized === "::1" ||
181
+ normalized === "[::1]"
182
+ );
102
183
  }
103
184
 
104
185
  export function providerBaseHost(hostname: string | undefined): string {
@@ -107,16 +188,34 @@ export function providerBaseHost(hostname: string | undefined): string {
107
188
  // Match what the server actually binds. Writing "localhost" while binding IPv4-only
108
189
  // 127.0.0.1 breaks on Windows, where localhost commonly resolves to ::1 first.
109
190
  if (lower === "::1" || lower === "[::1]") return "[::1]";
110
- if (isLoopbackHostname(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1";
191
+ if (
192
+ isLoopbackHostname(trimmed) ||
193
+ trimmed === "0.0.0.0" ||
194
+ trimmed === "::" ||
195
+ trimmed === "[::]"
196
+ )
197
+ return "127.0.0.1";
111
198
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
112
199
  return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
113
200
  }
114
201
 
115
- export function shouldInjectApiAuthHeader(config: Pick<OcxConfig, "hostname"> | undefined): boolean {
202
+ export function shouldInjectApiAuthHeader(
203
+ config: Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener"> | undefined,
204
+ ): boolean {
205
+ // The unauthenticated loopback listener is a loopback bind, so it admits without a
206
+ // credential (#1102). Emitting the env header anyway would be worse than useless: the
207
+ // directly-spawned app-server this exists for has no OPENCODEX_API_AUTH_TOKEN in its
208
+ // environment, and Codex would send an empty header value.
209
+ if (config?.unauthenticatedLoopbackListener?.enabled) return false;
116
210
  return !isLoopbackHostname(config?.hostname);
117
211
  }
118
212
 
119
- export function buildProviderTableBlock(port: number, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string {
213
+ export function buildProviderTableBlock(
214
+ port: number,
215
+ supportsWebsockets = false,
216
+ includeApiAuthHeader = false,
217
+ hostname?: string,
218
+ ): string {
120
219
  const host = providerBaseHost(hostname);
121
220
  const lines = [
122
221
  "",
@@ -128,13 +227,22 @@ export function buildProviderTableBlock(port: number, supportsWebsockets = false
128
227
  "requires_openai_auth = true",
129
228
  ];
130
229
  if (includeApiAuthHeader) {
131
- lines.push('env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }');
230
+ // codex-cli 0.146+ contract (#2073): env_key sends Authorization: Bearer $VAR and
231
+ // hard-errors on a missing/empty variable instead of silently omitting auth. It
232
+ // coexists with requires_openai_auth (env_key wins wire auth; the flag keeps the
233
+ // login/account UX), and the server substitutes stored main auth for our admission
234
+ // bearer (#1686), so the modern form is strictly better than the legacy
235
+ // env_http_headers table this line used to emit.
236
+ lines.push('env_key = "OPENCODEX_API_AUTH_TOKEN"');
132
237
  }
133
238
  if (supportsWebsockets) lines.push("supports_websockets = true");
134
239
  return lines.join("\n") + "\n";
135
240
  }
136
241
 
137
- export function buildOpenaiBaseUrlLine(port: number, hostname?: string): string {
242
+ export function buildOpenaiBaseUrlLine(
243
+ port: number,
244
+ hostname?: string,
245
+ ): string {
138
246
  return `openai_base_url = "http://${providerBaseHost(hostname)}:${port}/v1"`;
139
247
  }
140
248
 
@@ -144,9 +252,13 @@ export function buildOpenaiBaseUrlLine(port: number, hostname?: string): string
144
252
  * in place. A user's OWN root `openai_base_url` (no marker above it) is respected — we keep it
145
253
  * and inject nothing, reporting `keptUserBaseUrl` so the caller can surface it.
146
254
  */
147
- export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: string): { content: string; keptUserBaseUrl: boolean } {
255
+ export function setRootOpenaiBaseUrl(
256
+ content: string,
257
+ port: number,
258
+ hostname?: string,
259
+ ): { content: string; keptUserBaseUrl: boolean } {
148
260
  const lines = content.split("\n");
149
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
261
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
150
262
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
151
263
  const key = buildOpenaiBaseUrlLine(port, hostname);
152
264
 
@@ -159,7 +271,16 @@ export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: s
159
271
  }
160
272
 
161
273
  if (firstTable === -1) {
162
- return { content: content.replace(/\n+$/, "") + "\n" + OCX_SECTION_MARKER + "\n" + key + "\n", keptUserBaseUrl: false };
274
+ return {
275
+ content:
276
+ content.replace(/\n+$/, "") +
277
+ "\n" +
278
+ OCX_SECTION_MARKER +
279
+ "\n" +
280
+ key +
281
+ "\n",
282
+ keptUserBaseUrl: false,
283
+ };
163
284
  }
164
285
  let insertAt = firstTable;
165
286
  while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
@@ -174,7 +295,7 @@ export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: s
174
295
  */
175
296
  export function stripInjectedOpenaiBaseUrl(content: string): string {
176
297
  const lines = content.split("\n");
177
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
298
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
178
299
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
179
300
  const drop = new Set<number>();
180
301
  for (let i = 0; i < rootEnd; i++) {
@@ -190,7 +311,8 @@ export function stripInjectedOpenaiBaseUrl(content: string): string {
190
311
  return lines.filter((_, i) => !drop.has(i)).join("\n");
191
312
  }
192
313
 
193
- export type CodexRoutingKind = "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
314
+ export type CodexRoutingKind =
315
+ "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
194
316
 
195
317
  type RoutingEndpointKind = "local" | "remote" | "unknown";
196
318
 
@@ -198,7 +320,7 @@ function ipv4Octets(hostname: string): number[] | null {
198
320
  const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
199
321
  if (dotted) {
200
322
  const octets = dotted.slice(1).map(Number);
201
- return octets.some(octet => octet > 255) ? null : octets;
323
+ return octets.some((octet) => octet > 255) ? null : octets;
202
324
  }
203
325
  const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname);
204
326
  if (!mapped) return null;
@@ -211,13 +333,18 @@ function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
211
333
  try {
212
334
  const url = new URL(value);
213
335
  if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown";
214
- const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
336
+ const hostname = url.hostname
337
+ .toLowerCase()
338
+ .replace(/^\[|\]$/g, "")
339
+ .replace(/\.$/, "");
215
340
  if (!hostname) return "unknown";
216
- if (hostname === "localhost" || hostname.endsWith(".localhost")) return "local";
217
- if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0") return "local";
341
+ if (hostname === "localhost" || hostname.endsWith(".localhost"))
342
+ return "local";
343
+ if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0")
344
+ return "local";
218
345
  const octets = ipv4Octets(hostname);
219
346
  if (octets) {
220
- if (octets.every(octet => octet === 0)) return "local";
347
+ if (octets.every((octet) => octet === 0)) return "local";
221
348
  if (octets[0] === 127) return "local";
222
349
  return "remote";
223
350
  }
@@ -239,15 +366,25 @@ export function classifyCodexRouting(content: string): CodexRoutingKind {
239
366
  }
240
367
  const rootProvider = rootTomlString(content, "model_provider");
241
368
  if (rootProvider) {
242
- const providerTableExists = providerTableStart(content.split("\n"), rootProvider) !== -1;
243
- const providerBaseUrl = providerTableString(content, rootProvider, "base_url");
369
+ const providerTableExists =
370
+ providerTableStart(content.split("\n"), rootProvider) !== -1;
371
+ const providerBaseUrl = providerTableString(
372
+ content,
373
+ rootProvider,
374
+ "base_url",
375
+ );
244
376
  if (providerBaseUrl) {
245
377
  const endpoint = classifyRoutingEndpoint(providerBaseUrl);
246
378
  if (endpoint === "unknown") return "unknown";
247
379
  if (rootProvider === "opencodex") return "opencodex-local";
248
380
  return endpoint === "local" ? "custom-local" : "custom-remote";
249
381
  }
250
- if (rootProvider === "opencodex" || providerTableExists || rootProvider !== "openai") return "unknown";
382
+ if (
383
+ rootProvider === "opencodex" ||
384
+ providerTableExists ||
385
+ rootProvider !== "openai"
386
+ )
387
+ return "unknown";
251
388
  }
252
389
  return "native";
253
390
  }
@@ -282,7 +419,7 @@ export function getCodexRoutingKind(): CodexRoutingKind {
282
419
  */
283
420
  function stripExistingModelProvider(content: string): string {
284
421
  const lines = content.split("\n");
285
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
422
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
286
423
  const out: string[] = [];
287
424
  lines.forEach((line, i) => {
288
425
  if (/^\s*model_provider\s*=/.test(line)) {
@@ -303,7 +440,7 @@ function stripExistingModelProvider(content: string): string {
303
440
  */
304
441
  export function stripRootContextWindowOverrides(content: string): string {
305
442
  const lines = content.split("\n");
306
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
443
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
307
444
  return lines
308
445
  .filter((line, i) => {
309
446
  const isRoot = firstTable === -1 || i < firstTable;
@@ -314,12 +451,12 @@ export function stripRootContextWindowOverrides(content: string): string {
314
451
 
315
452
  function stripRootRoutedModel(content: string): string {
316
453
  const lines = content.split("\n");
317
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
454
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
318
455
  return lines
319
456
  .filter((line, i) => {
320
457
  const isRoot = firstTable === -1 || i < firstTable;
321
458
  if (!isRoot) return true;
322
- const m = line.match(/^\s*model\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
459
+ const m = line.match(/^\s*model\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*$/);
323
460
  if (!m) return true;
324
461
  const model = parseTomlString(m[1]);
325
462
  return !model?.includes("/");
@@ -333,7 +470,7 @@ function stripRootRoutedModel(content: string): string {
333
470
  */
334
471
  function setRootModelProvider(content: string): string {
335
472
  const lines = content.split("\n");
336
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
473
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
337
474
  const key = 'model_provider = "opencodex"';
338
475
  if (firstTable === -1) {
339
476
  return content.replace(/\n+$/, "") + "\n" + key + "\n";
@@ -345,23 +482,47 @@ function setRootModelProvider(content: string): string {
345
482
  }
346
483
 
347
484
  function readRootModelCatalogPath(content: string): string | null {
348
- return readRootTomlString(content, "model_catalog_json");
485
+ const lines = content.split("\n");
486
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
487
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
488
+ const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
489
+ let ownedCatalogPath: string | null = null;
490
+ for (let index = 0; index < rootEnd; index += 1) {
491
+ const match = modelCatalogAssignment.exec(lines[index]);
492
+ if (!match) continue;
493
+ const catalogPath = parseTomlString(match[1]);
494
+ if (!isOpencodexCatalogPath(catalogPath)) return catalogPath;
495
+ ownedCatalogPath ??= catalogPath;
496
+ }
497
+ return ownedCatalogPath;
349
498
  }
350
499
 
351
500
  function setRootModelCatalogPath(content: string, catalogPath: string): string {
352
501
  const lines = content.split("\n");
353
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
502
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
354
503
  const key = `model_catalog_json = ${tomlString(catalogPath)}`;
504
+ const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
355
505
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
506
+ const ownedAssignments: number[] = [];
507
+ let hasUserAssignment = false;
356
508
  for (let i = 0; i < rootEnd; i++) {
357
- const m = lines[i].match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
509
+ const m = modelCatalogAssignment.exec(lines[i]);
358
510
  if (!m) continue;
359
511
  const existing = parseTomlString(m[1]);
360
512
  if (isOpencodexCatalogPath(existing)) {
361
- lines[i] = key;
362
- return lines.join("\n");
513
+ ownedAssignments.push(i);
514
+ } else {
515
+ hasUserAssignment = true;
363
516
  }
364
- return content;
517
+ }
518
+ if (hasUserAssignment) {
519
+ const owned = new Set(ownedAssignments);
520
+ return lines.filter((_, index) => !owned.has(index)).join("\n");
521
+ }
522
+ if (ownedAssignments.length > 0) {
523
+ lines[ownedAssignments[0]] = key;
524
+ const duplicates = new Set(ownedAssignments.slice(1));
525
+ return lines.filter((_, index) => !duplicates.has(index)).join("\n");
365
526
  }
366
527
  if (firstTable === -1) {
367
528
  return content.replace(/\n+$/, "") + "\n" + key + "\n";
@@ -390,32 +551,52 @@ function removeProfileSection(content: string): string {
390
551
  }
391
552
  filtered.push(line);
392
553
  }
393
- return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
554
+ return (
555
+ filtered
556
+ .join("\n")
557
+ .replace(/\n{3,}/g, "\n\n")
558
+ .trimEnd() + "\n"
559
+ );
394
560
  }
395
561
 
396
562
  function normalizeServiceTier(content: string): string {
397
- return content.replace(/^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, '$1"fast"');
563
+ return content.replace(
564
+ /^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm,
565
+ '$1"fast"',
566
+ );
398
567
  }
399
568
 
400
- function ensureFastModeFeature(content: string): string {
569
+ function ensureFastModeFeature(content: string, fastMode?: boolean): string {
570
+ // Tri-state fast mode (see OcxConfig.fastMode): true forces `fast_mode = true`,
571
+ // false forces `fast_mode = false`, and undefined leaves the user's config
572
+ // untouched (no [features] table is added and an existing fast_mode line is
573
+ // preserved as-is). Table and key matching accept the valid TOML spellings
574
+ // `[features] # comment`, `["features"]` / `['features']`, and quoted keys.
401
575
  const lines = content.split("\n");
402
- const featuresStart = lines.findIndex(line => line.trim() === "[features]");
576
+ const featuresHeader = /^\s*\[(["']?)\s*features\s*\1\]\s*(?:#.*)?$/;
577
+ const fastModeKey = /^\s*(?:"fast_mode"|'fast_mode'|fast_mode)\s*=/;
578
+ const featuresStart = lines.findIndex(line => featuresHeader.test(line));
403
579
  if (featuresStart === -1) {
404
- return content.trimEnd() + "\n\n[features]\nfast_mode = true\n";
580
+ if (fastMode === undefined) return content;
581
+ return content.trimEnd() + "\n\n[features]\nfast_mode = " + (fastMode ? "true" : "false") + "\n";
405
582
  }
406
583
 
407
- const nextTable = lines.findIndex((line, index) => index > featuresStart && /^\s*\[/.test(line));
584
+ const nextTable = lines.findIndex(
585
+ (line, index) => index > featuresStart && /^\s*\[/.test(line),
586
+ );
408
587
  const featuresEnd = nextTable === -1 ? lines.length : nextTable;
409
588
  for (let i = featuresStart + 1; i < featuresEnd; i++) {
410
- if (/^\s*fast_mode\s*=/.test(lines[i])) {
411
- lines[i] = lines[i].replace(/^(\s*)fast_mode\s*=.*$/, "$1fast_mode = true");
589
+ if (fastModeKey.test(lines[i])) {
590
+ if (fastMode === undefined) return lines.join("\n");
591
+ lines[i] = lines[i].replace(/^(\s*)(?:"fast_mode"|'fast_mode'|fast_mode)\s*=.*$/, `$1fast_mode = ${fastMode ? "true" : "false"}`);
412
592
  return lines.join("\n");
413
593
  }
414
594
  }
415
595
 
596
+ if (fastMode === undefined) return lines.join("\n");
416
597
  let insertAt = featuresEnd;
417
598
  while (insertAt > featuresStart + 1 && lines[insertAt - 1].trim() === "") insertAt--;
418
- lines.splice(insertAt, 0, "fast_mode = true");
599
+ lines.splice(insertAt, 0, `fast_mode = ${fastMode ? "true" : "false"}`);
419
600
  return lines.join("\n");
420
601
  }
421
602
 
@@ -424,16 +605,20 @@ function isOpencodexCatalogPath(path: string): boolean {
424
605
  }
425
606
 
426
607
  function stripOpencodexCatalogPath(content: string): string {
427
- return content
428
- .split("\n")
429
- .filter(line => {
430
- const m = line.match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
608
+ const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
609
+ const lines = content.split("\n");
610
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
611
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
612
+ return lines
613
+ .filter((line, index) => {
614
+ if (index >= rootEnd) return true;
615
+ const m = modelCatalogAssignment.exec(line);
431
616
  return !m || !isOpencodexCatalogPath(parseTomlString(m[1]));
432
617
  })
433
618
  .join("\n");
434
619
  }
435
620
 
436
- export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string {
621
+ export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string, fastMode?: boolean): string {
437
622
  const host = providerBaseHost(hostname);
438
623
  // Design B (loopback): the reference/fallback file documents the root override form.
439
624
  // Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry
@@ -446,7 +631,7 @@ export function buildProfileFile(port: number, catalogPath?: string | null, supp
446
631
  buildOpenaiBaseUrlLine(port, hostname),
447
632
  ];
448
633
  if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
449
- lines.push("", "[features]", "fast_mode = true", "");
634
+ if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`, "");
450
635
  return lines.join("\n");
451
636
  }
452
637
  const lines = [
@@ -455,18 +640,22 @@ export function buildProfileFile(port: number, catalogPath?: string | null, supp
455
640
  'model_provider = "opencodex"',
456
641
  ];
457
642
  if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
458
- lines.push("", "[features]", "fast_mode = true");
643
+ if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`);
459
644
  lines.push(buildProviderTableBlock(port, supportsWebsockets, includeApiAuthHeader, hostname).trimEnd(), "");
460
645
  return lines.join("\n");
461
646
  }
462
647
 
463
- export function chooseCatalogPathForInjection(content: string, requested?: string | null): string | null {
648
+ export function chooseCatalogPathForInjection(
649
+ content: string,
650
+ requested?: string | null,
651
+ ): string | null {
464
652
  if (requested !== undefined) return requested;
465
653
 
466
654
  const existing = readRootModelCatalogPath(content);
467
655
  if (existing) {
468
656
  const resolved = resolveCodexConfigPath(existing);
469
- if (!isOpencodexCatalogPath(resolved) || existsSync(resolved)) return existing;
657
+ if (!isOpencodexCatalogPath(resolved) || existsSync(resolved))
658
+ return existing;
470
659
  }
471
660
 
472
661
  return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null;
@@ -475,12 +664,32 @@ export function chooseCatalogPathForInjection(content: string, requested?: strin
475
664
  export interface CodexInjectResult {
476
665
  success: boolean;
477
666
  message: string;
667
+ status?: "skipped";
668
+ skippedReason?: "desired_disabled" | "desired_enabled";
478
669
  nativeSubagentDefaultsWarning?: string;
479
670
  }
480
671
 
481
- export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise<CodexInjectResult> {
672
+ export async function injectCodexConfig(
673
+ port: number,
674
+ config?: OcxConfig,
675
+ options: InjectCodexOptions = {},
676
+ ): Promise<CodexInjectResult> {
677
+ // Point Codex at the unauthenticated loopback listener when it is enabled (#1102).
678
+ //
679
+ // Resolved here rather than at the call sites because every caller already passes the proxy
680
+ // port and the config together: startup sync, `ocx sync`, and the ensure path would each
681
+ // need the same two-line change, and a caller that missed it would silently emit a base_url
682
+ // requiring a credential the directly-spawned app-server does not have.
683
+ //
684
+ // The listener port is fixed in config, never OS-assigned, so this value survives restarts
685
+ // and matches what an already-running app-server read at startup.
686
+ const loopback = config?.unauthenticatedLoopbackListener;
687
+ if (loopback?.enabled) port = loopback.port;
482
688
  if (!existsSync(CODEX_CONFIG_PATH)) {
483
- return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
689
+ return {
690
+ success: false,
691
+ message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?`,
692
+ };
484
693
  }
485
694
 
486
695
  const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
@@ -488,14 +697,19 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
488
697
  if (activeProvider) {
489
698
  // A launcher may have journaled before the provider manager took ownership. Never let shutdown
490
699
  // replay that stale snapshot over externally managed config.
491
- removeJournal();
492
- const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(config)
700
+ if (!options.validateOnly) removeJournal();
701
+ const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(
702
+ config,
703
+ )
493
704
  ? `Native Codex sub-agent defaults were not injected: external model_provider ${tomlString(activeProvider)} owns config.toml.`
494
705
  : undefined;
495
706
  return {
496
707
  success: true,
497
- ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
498
- message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` +
708
+ ...(nativeSubagentDefaultsWarning
709
+ ? { nativeSubagentDefaultsWarning }
710
+ : {}),
711
+ message:
712
+ `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` +
499
713
  ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` +
500
714
  ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` +
501
715
  `${shouldInjectApiAuthHeader(config) ? ` with x-opencodex-api-key from OPENCODEX_API_AUTH_TOKEN` : ""}.\n` +
@@ -508,23 +722,37 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
508
722
  // root routing key: inserting that key ahead of a marker-owned first table
509
723
  // would otherwise separate the table marker from its header. Ambiguous
510
724
  // markers fail closed without writing config, profile, or journal state.
511
- const nativeDefaultsBaseline = transformManagedSubagentDefaults(rawContent, null);
725
+ const nativeDefaultsBaseline = transformManagedSubagentDefaults(
726
+ rawContent,
727
+ null,
728
+ );
512
729
  if (!nativeDefaultsBaseline.ok) {
513
730
  return {
514
731
  success: false,
515
- message: `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. `
516
- + `No files were changed; inspect ${CODEX_CONFIG_PATH}.`,
732
+ message:
733
+ `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` +
734
+ `No files were changed; inspect ${CODEX_CONFIG_PATH}.`,
517
735
  };
518
736
  }
519
737
  const baselineContent = nativeDefaultsBaseline.content;
520
738
 
521
- // Classify and journal the same bytes: a native config is a valid original and
522
- // supersedes a stale snapshot (#477), while an injected one must never become
523
- // one that is how opencodex routing would survive `ocx stop`.
524
- writeJournal({
525
- currentStateIsNative: !hasInjectedCodexRouting(rawContent),
526
- configContent: baselineContent,
527
- });
739
+ /*
740
+ * The journal write used to happen HERE, before the transforms. It now happens
741
+ * inside the write lock further down, and the transforms were hoisted above it
742
+ * rather than the lock being narrowed to the three file writes.
743
+ *
744
+ * Why: the lock's witness hashes the CANDIDATE BYTES, and those are not final
745
+ * until `profileContent` and the EOL-applied `content` exist. Opening the lock
746
+ * before them would leave nothing to hash; keeping the journal outside the
747
+ * lock would leave the first artifact-creating write unserialized, which is
748
+ * the hole this edge exists to close.
749
+ *
750
+ * The move is safe because the region between here and the writes performs no
751
+ * filesystem mutation — its only touch is `existsSync` on the catalog paths
752
+ * (`chooseCatalogPathForInjection`) — and because `writeJournal` is called
753
+ * with `configContent`, so it snapshots the baseline it is handed rather than
754
+ * rereading `config.toml` underneath the transforms.
755
+ */
528
756
  // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write.
529
757
  const eol = dominantEol(rawContent);
530
758
  let content = applyEol(baselineContent, "\n");
@@ -535,17 +763,22 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
535
763
  // Design B form FIRST: removeOcxSection also keys on the marker line, so a root-level
536
764
  // marker + openai_base_url pair must be gone before it scans or it would swallow root keys.
537
765
  content = stripInjectedOpenaiBaseUrl(content);
538
- if (content.includes("[model_providers.opencodex]")) {
766
+ if (hasOcxProviderTable(content)) {
539
767
  content = removeOcxSection(content);
540
768
  }
541
769
  content = removeProfileSection(content);
542
770
  content = stripExistingModelProvider(content);
543
771
  content = stripRootContextWindowOverrides(content);
544
772
  content = normalizeServiceTier(content);
545
- content = ensureFastModeFeature(content);
773
+ content = ensureFastModeFeature(content, config?.fastMode);
546
774
 
547
- const catalogPath = chooseCatalogPathForInjection(content, options.catalogPath);
548
- content = catalogPath ? setRootModelCatalogPath(content, catalogPath) : stripOpencodexCatalogPath(content);
775
+ const catalogPath = chooseCatalogPathForInjection(
776
+ content,
777
+ options.catalogPath,
778
+ );
779
+ content = catalogPath
780
+ ? setRootModelCatalogPath(content, catalogPath)
781
+ : stripOpencodexCatalogPath(content);
549
782
 
550
783
  const legacyMode = shouldInjectApiAuthHeader(config);
551
784
  let keptUserBaseUrl = false;
@@ -555,7 +788,15 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
555
788
  // 1) Root key BEFORE the first table header (must be a global, not nested under a table).
556
789
  content = setRootModelProvider(content);
557
790
  // 2) Provider table appended at EOF (position-independent).
558
- content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}), true, config?.hostname);
791
+ content =
792
+ content.trimEnd() +
793
+ "\n" +
794
+ buildProviderTableBlock(
795
+ port,
796
+ websocketsEnabled(config ?? {}),
797
+ true,
798
+ config?.hostname,
799
+ );
559
800
  } else {
560
801
  // Design B (loopback): a single root override; codex keeps its native `openai` provider id
561
802
  // so thread history is never remapped. Any legacy form was already stripped above.
@@ -566,66 +807,303 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
566
807
  }
567
808
 
568
809
  const desiredSubagentDefaults = configuredManagedSubagentDefaults(config);
569
- const routingOwnershipWarning = keptUserBaseUrl && desiredSubagentDefaults
570
- ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing."
571
- : undefined;
810
+ const routingOwnershipWarning =
811
+ keptUserBaseUrl && desiredSubagentDefaults
812
+ ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing."
813
+ : undefined;
572
814
  const managedDefaults = transformManagedSubagentDefaults(
573
815
  content,
574
816
  keptUserBaseUrl ? null : desiredSubagentDefaults,
575
817
  );
576
818
  let nativeSubagentDefaultsWarning = routingOwnershipWarning;
577
- let managedDefaultsMessage = routingOwnershipWarning ? ` ⚠️ ${routingOwnershipWarning}\n` : "";
819
+ let managedDefaultsMessage = routingOwnershipWarning
820
+ ? ` ⚠️ ${routingOwnershipWarning}\n`
821
+ : "";
578
822
  if (managedDefaults.ok) {
579
823
  content = managedDefaults.content;
580
824
  if (desiredSubagentDefaults && managedDefaults.conflicts.length > 0) {
581
- const keys = managedDefaults.conflicts.map(conflict => `agents.${conflict.key}`).join(", ");
825
+ const keys = managedDefaults.conflicts
826
+ .map((conflict) => `agents.${conflict.key}`)
827
+ .join(", ");
582
828
  nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults were not injected: user-owned ${keys} preserved.`;
583
829
  managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`;
584
830
  }
585
831
  } else {
586
- const action = desiredSubagentDefaults && !keptUserBaseUrl
587
- ? "were not injected"
588
- : "could not be safely removed";
832
+ const action =
833
+ desiredSubagentDefaults && !keptUserBaseUrl
834
+ ? "were not injected"
835
+ : "could not be safely removed";
589
836
  nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults ${action}: ${managedDefaults.error}.`;
590
837
  managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`;
591
838
  }
592
839
 
593
- const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname);
840
+ const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname, config?.fastMode);
594
841
  content = applyEol(content, eol);
595
- atomicWriteFile(CODEX_CONFIG_PATH, content);
596
- atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
597
- markJournalInjectedState(content, profileContent);
842
+
843
+ /*
844
+ * The witness, built from the FINAL bytes. Everything it hashes is either the
845
+ * output about to be written or evidence that can be re-read under the lock;
846
+ * ownership rides along as recorded context because it is not re-observed
847
+ * there — see `write-coordination.ts`.
848
+ */
849
+ const persisted = readConfigAdmissionSnapshot();
850
+ const persistedIdentity =
851
+ persisted.kind === "read" ? persisted.contentSha256 : "unreadable";
852
+ const observedGeneration = observeConfigGeneration();
853
+ const generation =
854
+ observedGeneration.kind === "ready"
855
+ ? { present: true, value: observedGeneration.generation.value }
856
+ : { present: false, value: 0 };
857
+ const candidate = {
858
+ configBytes: content,
859
+ profileBytes: profileContent,
860
+ catalogPath,
861
+ };
862
+ const witness = buildInjectWitness(
863
+ candidate,
864
+ rawContent,
865
+ persistedIdentity,
866
+ generation,
867
+ "unknown",
868
+ );
869
+
870
+ /*
871
+ * THE COORDINATED SECTION.
872
+ *
873
+ * This is the write lock's first production caller. Everything above is
874
+ * classification and pure transformation; everything from here to the end of
875
+ * the callback replaces files, and two processes doing it at once is the
876
+ * interruption hazard this substrate exists to close.
877
+ *
878
+ * The witness hashes the bytes about to be written rather than the inputs that
879
+ * produced them, so two operations intending different output cannot share an
880
+ * id no matter which input differed.
881
+ */
882
+ /*
883
+ * Eligibility BEFORE acquisition, never "try and fall back".
884
+ *
885
+ * A home routed before this substrate existed cannot have its first
886
+ * coordinator row created — the guard that refuses is correct — and that
887
+ * describes every pre-substrate install. Attempting the lock there would enter
888
+ * a refusal path on the entire installed base, so the decision happens first
889
+ * and those homes keep the write sequence they have always used.
890
+ */
891
+ const eligibility = codexWriteCoordinationEligibility({
892
+ coordinatorPath: () =>
893
+ resolveCodexCoordinatorDatabasePath(
894
+ resolveEffectiveUserIdentity(),
895
+ getCodexHome(),
896
+ ),
897
+ residue: () => classifyNativeRoutedResidue(),
898
+ integrationRecord: () => readIntegrationRecord(),
899
+ });
900
+ if (eligibility.kind === "refused") {
901
+ return {
902
+ success: false,
903
+ message: `Codex configuration was not written: ${eligibility.reason}.`,
904
+ };
905
+ }
906
+
907
+ if (options.validateOnly) {
908
+ return {
909
+ success: true,
910
+ message: "Codex config injection preflight passed; no files were changed.",
911
+ };
912
+ }
913
+
914
+ const applyNativeArtifacts = (): void => {
915
+ writeJournal({
916
+ currentStateIsNative: !hasInjectedCodexRouting(rawContent),
917
+ configContent: baselineContent,
918
+ });
919
+ atomicWriteFile(CODEX_CONFIG_PATH, content);
920
+ atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
921
+ markJournalInjectedState(content, profileContent, {
922
+ // A root override is ours only in loopback Design B when no user-owned value won.
923
+ injectedOpenaiBaseUrl: legacyMode || keptUserBaseUrl
924
+ ? null
925
+ : rootTomlString(content, "openai_base_url"),
926
+ // This is the catalog artifact selected for this injection, even when config.toml
927
+ // already points at that path and therefore needs no textual rewrite.
928
+ injectedCatalogPath: catalogPath,
929
+ });
930
+ };
931
+
932
+ /*
933
+ * Set only on the coordinated path: the generation/txId the transition just
934
+ * committed. The terminal history update CASes against this, so a job that
935
+ * was overtaken cannot overwrite the winner. Stays undefined for a
936
+ * legacy-uncoordinated home, which publishes no transition to resolve.
937
+ */
938
+ let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
939
+
940
+ if (eligibility.kind === "legacy-uncoordinated") {
941
+ // Unchanged behavior for homes the coordinator cannot yet adopt. Stated
942
+ // rather than implied: this is the boundary, and adoption is its own phase.
943
+ if (!shouldSyncCodexOnStart(loadConfig())) {
944
+ return {
945
+ success: true,
946
+ status: "skipped",
947
+ skippedReason: "desired_disabled",
948
+ message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
949
+ };
950
+ }
951
+ applyNativeArtifacts();
952
+ } else {
953
+ const coordinated = await withCodexWriteLock(
954
+ {
955
+ timeoutMs: options.lockTimeoutMs ?? DEFAULT_INJECT_LOCK_TIMEOUT_MS,
956
+ admitted: { authoritySnapshotId: witness.comparisonId },
957
+ readAdmissionUnderLock: () => ({
958
+ authoritySnapshotId: recomputeInjectWitness({
959
+ candidate: witness.candidate,
960
+ canonicalTargets: witness.evidence.canonicalTargets,
961
+ persistedIdentity,
962
+ generation,
963
+ observedOwnership: witness.observedOwnership,
964
+ }).comparisonId,
965
+ }),
966
+ },
967
+ (ctx) => {
968
+ if (!shouldSyncCodexOnStart(loadConfig())) {
969
+ throw new CodexWriteLockSkipped("desired_disabled");
970
+ }
971
+ /*
972
+ * Publish BEFORE touching the filesystem. `assertPublished` runs after this
973
+ * callback returns and throws unless a transition was recorded, so writing
974
+ * first would replace every file and only then fail — with SQLite rolling
975
+ * back and the filesystem staying changed.
976
+ *
977
+ * `beginTransition` returns a conflict rather than throwing, so its result
978
+ * is checked here; ignoring it would reach the same failure by a slower
979
+ * route.
980
+ */
981
+ const published = ctx.coordinator.beginTransition(
982
+ {
983
+ nativeGeneration: ctx.expectation.nativeBefore,
984
+ currentTxId: ctx.currentTxId,
985
+ },
986
+ {
987
+ txId: ctx.expectation.txId,
988
+ direction: "apply",
989
+ authoritySnapshotId: ctx.admission.authoritySnapshotId,
990
+ nextRetryAt: new Date().toISOString(),
991
+ },
992
+ );
993
+ if (published.kind !== "updated") {
994
+ throw new CodexWriteConflictError(
995
+ `The Codex transition could not be published: ${published.kind}.`,
996
+ );
997
+ }
998
+
999
+ /*
1000
+ * Exact pre-images, captured under the lock and used for compensation.
1001
+ *
1002
+ * A rolled-back coordinator row is not a rolled-back filesystem: each
1003
+ * `atomicWriteFile` is atomic alone, never across the three together, so a
1004
+ * failure partway leaves earlier replacements in place. `restoreJournalState`
1005
+ * cannot be the undo — it restores whichever journal occupies the path,
1006
+ * which need not be the one this operation wrote.
1007
+ */
1008
+ const preImages = captureCodexPreImages();
1009
+ try {
1010
+ applyNativeArtifacts();
1011
+ } catch (error) {
1012
+ // Compensate, then ALWAYS throw. Returning a partial result would let the
1013
+ // lock commit a row describing an apply that did not finish.
1014
+ const restored = restoreCodexPreImages(preImages);
1015
+ if (!restored.complete) {
1016
+ throw new CodexPartialWriteError(restored.unrestored);
1017
+ }
1018
+ throw error;
1019
+ }
1020
+ return {
1021
+ kind: "applied" as const,
1022
+ /*
1023
+ * The receipt the terminal update matches on. The transition commits
1024
+ * when the callback returns, so this pair is what the post-job
1025
+ * `updateCodexHistoryTransition` CASes against — an overtaken job
1026
+ * cannot overwrite a winner.
1027
+ */
1028
+ receipt: {
1029
+ nativeGeneration: ctx.expectation.nativeAfter,
1030
+ currentTxId: ctx.expectation.txId,
1031
+ },
1032
+ };
1033
+ },
1034
+ );
1035
+
1036
+ if (coordinated.status !== "acquired") {
1037
+ return codexInjectLockOutcome(coordinated);
1038
+ }
1039
+ transitionReceipt = coordinated.value.receipt;
1040
+ }
598
1041
  // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
599
1042
  // the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore
600
1043
  // machinery; cheap no-op when there is nothing to migrate).
601
- const history = config?.syncResumeHistory !== false
602
- ? (legacyMode ? syncCodexHistoryProvider("opencodex") : migrateHistoryToOpenai())
603
- : { rows: 0, files: 0 };
1044
+ // History runs in a Worker under H, not on this thread.
1045
+ //
1046
+ // The three surfaces it touches — the SQLite rows, the backup manifest, and the
1047
+ // rollout files — do not share a transaction, so a busy timeout only ever
1048
+ // serialized one of them and an opposite-direction process could overtake
1049
+ // through the other two. The operation is derived from admitted intent here and
1050
+ // handed down fixed; the Worker never takes a direction from its caller.
1051
+ const historyOutcome = await runCodexHistoryJob({
1052
+ ...resolveCodexHistoryJobTarget(),
1053
+ expectedDesiredEnabled: true,
1054
+ operation: deriveCodexHistoryOperation({
1055
+ direction: "apply",
1056
+ resumeHistory: config?.syncResumeHistory !== false,
1057
+ legacyMode,
1058
+ }),
1059
+ });
1060
+ // A blocked or failed unit is reported, not silently counted as zero work:
1061
+ // `failed` is what makes the caller's message say so.
1062
+ const history: { rows: number; files: number; failed?: true } =
1063
+ historyOutcome.kind === "converged"
1064
+ ? { rows: historyOutcome.rows, files: historyOutcome.files }
1065
+ : historyOutcome.kind === "skipped"
1066
+ ? { rows: 0, files: 0 }
1067
+ : { rows: 0, files: 0, failed: true };
1068
+
1069
+ /*
1070
+ * Resolve the transition this job belongs to, on the coordinated path only.
1071
+ *
1072
+ * `updateCodexHistoryTransition` had no production caller since it was
1073
+ * written, so every completed or skipped job left the row permanently
1074
+ * `pending` — the transition was published and never resolved. This is the
1075
+ * first time the durable row reflects what actually happened. The CAS on the
1076
+ * receipt means an overtaken job's late write loses and is not overwritten.
1077
+ */
1078
+ if (transitionReceipt) {
1079
+ resolveCodexHistoryTransition(transitionReceipt, historyOutcome);
1080
+ }
604
1081
 
605
1082
  const catalogMessage = catalogPath
606
1083
  ? ` Codex model catalog: ${catalogPath}\n`
607
1084
  : ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`;
608
- const migratedRows = (history.rows ?? 0) + ("ejectedRows" in history ? history.ejectedRows ?? 0 : 0);
609
- const historyMessage = config?.syncResumeHistory === false
610
- ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
611
- : history.failed
612
- ? (legacyMode
613
- ? ` ⚠️ Codex resume history sync SKIPPED: the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'.\n`
614
- // Honest in every caller context: the daemon retries in the background while it runs,
615
- // and this inject path re-runs the migration on every future start/sync anyway.
616
- : ` ⚠️ Codex resume history migration deferred: the history DB is locked (Codex app/IDE open?). It is retried automatically (while the proxy runs and on every 'ocx start'); to force it now, close the Codex app and run 'ocx sync'.\n`)
617
- : legacyMode
618
- ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
619
- : migratedRows > 0
620
- ? ` Codex resume history: ${migratedRows} legacy opencodex-tagged thread(s) migrated back to openai (one-time).\n`
621
- : ` Codex resume history: untouched (threads keep their native openai tag).\n`;
1085
+ const ejected = (history as { ejectedRows?: number }).ejectedRows ?? 0;
1086
+ const migratedRows = (history.rows ?? 0) + ejected;
1087
+ const historyMessage =
1088
+ config?.syncResumeHistory === false
1089
+ ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
1090
+ : history.failed
1091
+ ? formatApplyHistoryFailure(historyOutcome, legacyMode)
1092
+ : legacyMode
1093
+ ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
1094
+ : migratedRows > 0
1095
+ ? ` Codex resume history: ${migratedRows} legacy opencodex-tagged thread(s) migrated back to openai (one-time).\n`
1096
+ : ` Codex resume history: untouched (threads keep their native openai tag).\n`;
622
1097
  // A user-owned root openai_base_url means we did NOT install routing — say so honestly
623
1098
  // instead of claiming the proxy route is active (catalog/fast_mode were still written).
624
1099
  if (keptUserBaseUrl) {
625
1100
  return {
626
1101
  success: true,
627
- ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
628
- message: `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` +
1102
+ ...(nativeSubagentDefaultsWarning
1103
+ ? { nativeSubagentDefaultsWarning }
1104
+ : {}),
1105
+ message:
1106
+ `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` +
629
1107
  catalogMessage +
630
1108
  historyMessage +
631
1109
  managedDefaultsMessage +
@@ -639,7 +1117,8 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
639
1117
  return {
640
1118
  success: true,
641
1119
  ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
642
- message: headline +
1120
+ message:
1121
+ headline +
643
1122
  catalogMessage +
644
1123
  historyMessage +
645
1124
  managedDefaultsMessage +
@@ -652,19 +1131,47 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
652
1131
  };
653
1132
  }
654
1133
 
1134
+ /**
1135
+ * Sub-table headers like `[model_providers.opencodex.env_http_headers]` appear when a Codex app
1136
+ * config rewrite re-serializes the provider's inline `env_http_headers` table. They define the
1137
+ * same `model_providers.opencodex` provider, so cleanup must remove them too — otherwise the
1138
+ * provider survives with no `name` and Codex rejects the whole config
1139
+ * ("provider name must not be empty"). The dot terminator keeps a user's
1140
+ * `[model_providers.opencodex_backup]`-style tables out of scope.
1141
+ */
1142
+ function isOcxProviderHeaderLine(trimmedLine: string): boolean {
1143
+ // Root form matched by regex, not equality: TOML v1.0 allows a trailing comment
1144
+ // (`[model_providers.opencodex] # comment`), and an exact compare would miss that form.
1145
+ // The sub-table prefix check already tolerates trailing comments by construction.
1146
+ return (
1147
+ /^\[model_providers\.opencodex\]\s*(?:#.*)?$/.test(trimmedLine) ||
1148
+ trimmedLine.startsWith("[model_providers.opencodex.")
1149
+ );
1150
+ }
1151
+
1152
+ function hasOcxProviderTable(content: string): boolean {
1153
+ return content
1154
+ .split("\n")
1155
+ .some((line) => isOcxProviderHeaderLine(line.trim()));
1156
+ }
1157
+
655
1158
  function removeOcxSection(content: string): string {
656
1159
  const lines = content.split("\n");
657
1160
  const filtered: string[] = [];
658
1161
  let inOcxSection = false;
659
1162
  for (const line of lines) {
660
- if (line.includes(OCX_SECTION_MARKER) || line.trim() === "[model_providers.opencodex]") {
1163
+ if (
1164
+ line.includes(OCX_SECTION_MARKER) ||
1165
+ isOcxProviderHeaderLine(line.trim())
1166
+ ) {
661
1167
  inOcxSection = true;
662
1168
  continue;
663
1169
  }
664
1170
  if (inOcxSection) {
665
- // End the injected section at the next table header that ISN'T our own exact match so a
666
- // user's "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
667
- if (/^\s*\[/.test(line) && line.trim() !== "[model_providers.opencodex]") {
1171
+ // End the injected section at the next table header that ISN'T our own. Exact match on the
1172
+ // provider name (plus our own sub-tables) so a user's
1173
+ // "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
1174
+ if (/^\s*\[/.test(line) && !isOcxProviderHeaderLine(line.trim())) {
668
1175
  inOcxSection = false;
669
1176
  filtered.push(line);
670
1177
  }
@@ -672,7 +1179,12 @@ function removeOcxSection(content: string): string {
672
1179
  }
673
1180
  filtered.push(line);
674
1181
  }
675
- return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
1182
+ return (
1183
+ filtered
1184
+ .join("\n")
1185
+ .replace(/\n{3,}/g, "\n\n")
1186
+ .trimEnd() + "\n"
1187
+ );
676
1188
  }
677
1189
 
678
1190
  interface StripOpencodexConfigResult {
@@ -685,18 +1197,30 @@ interface StripOpencodexConfigResult {
685
1197
  * ambiguous: keep the associated value, but return the transform error so the
686
1198
  * caller cannot report a complete restore.
687
1199
  */
688
- function stripOpencodexConfigResult(content: string): StripOpencodexConfigResult {
1200
+ function stripOpencodexConfigResult(
1201
+ content: string,
1202
+ journaledBaseUrl: string | null = null,
1203
+ ): StripOpencodexConfigResult {
689
1204
  let out = content;
690
- const hadRootOcxProvider = readRootTomlString(out, "model_provider") === "opencodex";
691
- const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out);
1205
+ const hadRootOcxProvider =
1206
+ readRootTomlString(out, "model_provider") === "opencodex";
1207
+ // #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values
1208
+ // while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded
1209
+ // writing -- so an app-rewritten config is still recognized as ours.
1210
+ const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out)
1211
+ || (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl);
692
1212
  out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
693
- if (out.includes("[model_providers.opencodex]")) {
1213
+ out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl);
1214
+ if (hasOcxProviderTable(out)) {
694
1215
  out = removeOcxSection(out);
695
1216
  }
696
1217
  out = removeProfileSection(out);
697
1218
  // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
698
1219
  // must match the detection regex above, or a detected line could survive un-removed.
699
- out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
1220
+ out = out
1221
+ .split("\n")
1222
+ .filter((l) => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l))
1223
+ .join("\n");
700
1224
  // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves
701
1225
  // them — strip on both the legacy re-tag form and the Design B injected-base-url form.
702
1226
  if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out);
@@ -715,14 +1239,19 @@ export function stripOpencodexConfig(content: string): string {
715
1239
  }
716
1240
 
717
1241
  function hasOpencodexRouting(content: string): boolean {
718
- return content.includes("[model_providers.opencodex]")
719
- || /^\s*model_provider\s*=\s*"opencodex"/m.test(content)
720
- || hasInjectedOpenaiBaseUrl(content);
1242
+ return (
1243
+ hasOcxProviderTable(content) ||
1244
+ /^\s*model_provider\s*=\s*"opencodex"/m.test(content) ||
1245
+ hasInjectedOpenaiBaseUrl(content)
1246
+ );
721
1247
  }
722
1248
 
723
- export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): { success: boolean; message: string } {
1249
+ export function removeCodexConfig(
1250
+ options: { preserveProfile?: boolean } = {},
1251
+ ): { success: boolean; message: string } {
724
1252
  if (!existsSync(CODEX_CONFIG_PATH)) {
725
- if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
1253
+ if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
1254
+ unlinkSync(CODEX_PROFILE_PATH);
726
1255
  return {
727
1256
  success: true,
728
1257
  message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
@@ -733,20 +1262,28 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}):
733
1262
  // The unchanged fast path compares in LF space so an untouched file is never rewritten.
734
1263
  const eol = dominantEol(rawContent);
735
1264
  const content = applyEol(rawContent, "\n");
736
- const had = hasOpencodexRouting(content);
737
- const stripped = stripOpencodexConfigResult(content);
1265
+ // Read the recorded injection once: the strip below consumes it, and so does the
1266
+ // ownership verdict, which must agree with what was actually removed.
1267
+ const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
1268
+ const had = hasOpencodexRouting(content)
1269
+ || (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl);
1270
+ const stripped = stripOpencodexConfigResult(content, journaledBaseUrl);
738
1271
  if (had || stripped.content !== content) {
739
1272
  atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol));
740
1273
  }
741
- if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
1274
+ if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
1275
+ unlinkSync(CODEX_PROFILE_PATH);
742
1276
  const removedMessage = had
743
1277
  ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}`
744
1278
  : "opencodex not present in Codex config.";
745
1279
  if (stripped.managedDefaultsError) {
746
- const routingMessage = had ? removedMessage : "No opencodex routing was present in Codex config.";
1280
+ const routingMessage = had
1281
+ ? removedMessage
1282
+ : "No opencodex routing was present in Codex config.";
747
1283
  return {
748
1284
  success: false,
749
- message: `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` +
1285
+ message:
1286
+ `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` +
750
1287
  "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.",
751
1288
  };
752
1289
  }
@@ -756,22 +1293,365 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}):
756
1293
  };
757
1294
  }
758
1295
 
1296
+ export type CodexRestoreArtifactState = "ok" | "skipped" | "failed";
1297
+
1298
+ export interface CodexRestoreConfigResult {
1299
+ state: CodexRestoreArtifactState;
1300
+ changed: boolean;
1301
+ action: "journal-restored" | "owned-fields-stripped" | "external-provider-preserved" | "failed";
1302
+ message: string;
1303
+ }
1304
+
1305
+ export interface CodexRestoreCatalogResult {
1306
+ state: CodexRestoreArtifactState;
1307
+ changed: boolean;
1308
+ removed: number;
1309
+ kept: number;
1310
+ path: string | null;
1311
+ message: string;
1312
+ }
1313
+
1314
+ export interface CodexRestoreHistoryResult {
1315
+ state: CodexRestoreArtifactState;
1316
+ changed: boolean;
1317
+ reason?: CodexHistoryFailureReason;
1318
+ rows: number;
1319
+ files: number;
1320
+ ejectedRows: number;
1321
+ message: string;
1322
+ }
1323
+
1324
+ export interface CodexNativeRestoreResult {
1325
+ success: boolean;
1326
+ message: string;
1327
+ externalProvider?: string;
1328
+ artifacts: {
1329
+ config: CodexRestoreConfigResult;
1330
+ catalog: CodexRestoreCatalogResult;
1331
+ history: CodexRestoreHistoryResult;
1332
+ };
1333
+ }
1334
+
1335
+ function failedHistoryRestore(reason?: CodexHistoryFailureReason, detail?: string): CodexRestoreHistoryResult {
1336
+ return {
1337
+ state: "failed",
1338
+ changed: false,
1339
+ ...(reason ? { reason } : {}),
1340
+ rows: 0,
1341
+ files: 0,
1342
+ ejectedRows: 0,
1343
+ message: reason === "permission"
1344
+ ? "Codex resume history could NOT be restored because permission was denied."
1345
+ : reason === "busy"
1346
+ ? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database."
1347
+ : detail
1348
+ ? `Codex resume history could NOT be restored: ${detail}`
1349
+ : "Codex resume history could NOT be restored; the reason was not recorded. Run 'ocx doctor'.",
1350
+ };
1351
+ }
1352
+
1353
+ /**
1354
+ * Restore failure wording for a Worker outcome.
1355
+ *
1356
+ * Only a genuine busy result blames the Codex app. An unsafe-path refusal, an
1357
+ * unavailable coordinator database, a permission denial, or a dead/timed-out
1358
+ * worker is a different problem; the old collapse made every one of those read
1359
+ * as "the Codex app is holding the database" (issue #1191). `busy` and
1360
+ * `permission` keep the restore-specific sentence built by
1361
+ * `failedHistoryRestore`; every other reason reuses the single formatter so
1362
+ * the two modules cannot drift apart.
1363
+ */
1364
+ export function failedHistoryRestoreFromOutcome(
1365
+ outcome: Extract<CodexHistoryJobOutcome, { kind: "blocked" | "failed" }>,
1366
+ ): CodexRestoreHistoryResult {
1367
+ if (outcome.kind === "blocked" && outcome.reason === "busy") return failedHistoryRestore("busy");
1368
+ if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") return failedHistoryRestore("busy");
1369
+ if (outcome.kind === "failed" && outcome.historyFailureReason === "permission") {
1370
+ return failedHistoryRestore("permission");
1371
+ }
1372
+ return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore"));
1373
+ }
1374
+
1375
+ function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult {
1376
+ const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`;
1377
+ return {
1378
+ success: true,
1379
+ message,
1380
+ externalProvider: activeProvider,
1381
+ artifacts: {
1382
+ config: { state: "skipped", changed: false, action: "external-provider-preserved", message },
1383
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1384
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1385
+ },
1386
+ };
1387
+ }
1388
+
1389
+ /** A foreign service claim is an authority boundary, including explicit CLI restore. */
1390
+ function foreignOwnershipRestoreRefusal(message: string): CodexNativeRestoreResult {
1391
+ return {
1392
+ success: false,
1393
+ message: `Codex native restore refused: ${message}`,
1394
+ artifacts: {
1395
+ config: { state: "skipped", changed: false, action: "failed", message },
1396
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1397
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1398
+ },
1399
+ };
1400
+ }
1401
+
1402
+ function desiredEnabledRestoreSkip(): CodexNativeRestoreResult {
1403
+ const message = "Codex integration was re-enabled; native restore was skipped.";
1404
+ return skippedRestoreEnvelope(true, message);
1405
+ }
1406
+
759
1407
  /**
760
- * Recover native Codex: strip opencodex from config.toml AND drop proxy-routed catalog entries,
761
- * so plain `codex` works when the proxy is stopped. Called by `ocx stop`, the proxy shutdown
762
- * handler, and `ocx restore`. Idempotent + atomic.
1408
+ * A schema-complete all-skipped envelope for outcomes decided before any
1409
+ * restore machinery runs. Every `restore --json` path must stay shape-stable
1410
+ * with `CodexNativeRestoreResult`; consumers never special-case early exits.
763
1411
  */
764
- export function restoreNativeCodex(): { success: boolean; message: string } {
1412
+ export function skippedRestoreEnvelope(success: boolean, message: string): CodexNativeRestoreResult {
1413
+ return {
1414
+ success,
1415
+ message,
1416
+ artifacts: {
1417
+ config: { state: "skipped", changed: false, action: "owned-fields-stripped", message },
1418
+ catalog: { state: "skipped", changed: false, removed: 0, kept: 0, path: null, message },
1419
+ history: { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message },
1420
+ },
1421
+ };
1422
+ }
1423
+
1424
+ /** The config/profile half of a native restore, reported as one artifact. */
1425
+ function restoreCodexConfigInline(): CodexRestoreConfigResult {
1426
+ try {
1427
+ const journal = restoreJournalState();
1428
+ const restored = journal.configRestored
1429
+ ? { success: true, message: "Codex config restored from opencodex journal." }
1430
+ : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
1431
+ return restored.success
1432
+ ? {
1433
+ state: "ok",
1434
+ changed: journal.configRestored || journal.profileRestored || journal.profileChanged || restored.message.startsWith("Removed"),
1435
+ action: journal.configRestored ? "journal-restored" : "owned-fields-stripped",
1436
+ message: restored.message,
1437
+ }
1438
+ : { state: "failed", changed: false, action: "failed", message: restored.message };
1439
+ } catch (error) {
1440
+ return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) };
1441
+ }
1442
+ }
1443
+
1444
+ /** The catalog half, always inside its own K acquisition. */
1445
+ /**
1446
+ * The catalog half, always inside its own K acquisition.
1447
+ *
1448
+ * `journaledCatalogPath` must be captured by the CALLER, before the config half runs: a
1449
+ * successful journal restore deletes the journal, and a config restore can remove
1450
+ * `model_catalog_json`. Reading it here would be too late in both cases (#1798).
1451
+ */
1452
+ function restoreCodexCatalogArtifact(
1453
+ revalidateDesiredState: boolean,
1454
+ journaledCatalogPath: string | null,
1455
+ ): CodexRestoreCatalogResult {
1456
+ const owningCodexHome = getCodexHome();
1457
+ try {
1458
+ const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
1459
+ revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
1460
+ ? null
1461
+ : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath));
1462
+ return restored.kind === "completed" && restored.value !== null
1463
+ ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
1464
+ : restored.kind === "completed"
1465
+ ? {
1466
+ state: "skipped", changed: false, removed: 0, kept: 0, path: null,
1467
+ message: "Codex integration was re-enabled; native catalog restoration was skipped.",
1468
+ }
1469
+ : {
1470
+ state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH,
1471
+ message: `Codex catalog could not be restored: ${restored.reason}.`,
1472
+ };
1473
+ } catch (error) {
1474
+ return {
1475
+ state: "failed", changed: false, removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH,
1476
+ message: error instanceof Error ? error.message : String(error),
1477
+ };
1478
+ }
1479
+ }
1480
+
1481
+ /**
1482
+ * Restore native Codex, running history in a Worker under H.
1483
+ *
1484
+ * On a coordinated home the config/profile restore happens INSIDE the Codex
1485
+ * write lock, publishing a `remove` transition — the same serialization inject
1486
+ * uses. Without it, an older restore could overwrite a config a concurrent
1487
+ * enable had just written under the lock, and then honestly report success
1488
+ * while desired intent said ON. The desired-state re-read under the lock turns
1489
+ * that lost race into the discriminated `desired_enabled` skip.
1490
+ */
1491
+ export async function restoreNativeCodexAsync(
1492
+ options: { revalidateDesiredState?: boolean } = {},
1493
+ ): Promise<CodexNativeRestoreResult> {
765
1494
  const activeProvider = currentExternalCodexModelProvider();
766
1495
  if (activeProvider) {
1496
+ // External-provider courtesy: only the stale journal is removed. The
1497
+ // history worker must not launch — it would turn a read-mostly courtesy
1498
+ // result into a history mutation on a home we do not own.
767
1499
  removeJournal();
768
- return { success: true, message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.` };
1500
+ return externalProviderRestoreResult(activeProvider);
1501
+ }
1502
+
1503
+ // `restore` normally honours a human request even when an unrelated
1504
+ // service-manager probe is unavailable. A recorded FOREIGN home is not an
1505
+ // unrelated probe: it is positive evidence another installation owns these
1506
+ // native artifacts, so do not create profile/claim locks before refusing.
1507
+ if (options.revalidateDesiredState) {
1508
+ const ownership = inspectNativeCodexOwnership();
1509
+ if (ownership.ownership === "foreign") return foreignOwnershipRestoreRefusal(ownership.reason);
769
1510
  }
770
- const journal = restoreJournalState();
771
- const cfg = journal.configRestored
772
- ? { success: true, message: "Codex config restored from opencodex journal." }
773
- : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
774
- const cat = restoreCodexCatalog();
1511
+
1512
+ const eligibility = codexWriteCoordinationEligibility({
1513
+ coordinatorPath: () =>
1514
+ resolveCodexCoordinatorDatabasePath(resolveEffectiveUserIdentity(), getCodexHome()),
1515
+ residue: () => classifyNativeRoutedResidue(),
1516
+ integrationRecord: () => readIntegrationRecord(),
1517
+ });
1518
+
1519
+ // Captured before the config half: a successful journal restore DELETES the journal, and
1520
+ // restoring the config can drop `model_catalog_json`. Either one would hide the routed
1521
+ // catalog we actually wrote (#1798).
1522
+ const journaledCatalogPath = journaledInjectedCatalogPath();
1523
+ let config: CodexRestoreConfigResult;
1524
+ let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
1525
+
1526
+ if (eligibility.kind === "coordinated") {
1527
+ // The restore has no candidate bytes to witness; freshness comes from the
1528
+ // filesystem reads and the desired-state re-read performed under the lock.
1529
+ const witness = { authoritySnapshotId: "codex-native-restore" };
1530
+ const coordinated = await withCodexWriteLock(
1531
+ {
1532
+ timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS,
1533
+ admitted: witness,
1534
+ readAdmissionUnderLock: () => witness,
1535
+ },
1536
+ (ctx) => {
1537
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
1538
+ throw new CodexWriteLockSkipped("desired_enabled");
1539
+ }
1540
+ const published = ctx.coordinator.beginTransition(
1541
+ {
1542
+ nativeGeneration: ctx.expectation.nativeBefore,
1543
+ currentTxId: ctx.currentTxId,
1544
+ },
1545
+ {
1546
+ txId: ctx.expectation.txId,
1547
+ direction: "remove",
1548
+ authoritySnapshotId: ctx.admission.authoritySnapshotId,
1549
+ nextRetryAt: new Date().toISOString(),
1550
+ },
1551
+ );
1552
+ if (published.kind !== "updated") {
1553
+ throw new CodexWriteConflictError(
1554
+ `The Codex transition could not be published: ${published.kind}.`,
1555
+ );
1556
+ }
1557
+ const preImages = captureCodexPreImages();
1558
+ let restored: CodexRestoreConfigResult;
1559
+ try {
1560
+ restored = restoreCodexConfigInline();
1561
+ } catch (error) {
1562
+ const compensated = restoreCodexPreImages(preImages);
1563
+ if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
1564
+ throw error;
1565
+ }
1566
+ return {
1567
+ config: restored,
1568
+ receipt: {
1569
+ nativeGeneration: ctx.expectation.nativeAfter,
1570
+ currentTxId: ctx.expectation.txId,
1571
+ },
1572
+ };
1573
+ },
1574
+ );
1575
+ if (coordinated.status === "skipped") return desiredEnabledRestoreSkip();
1576
+ if (coordinated.status !== "acquired") {
1577
+ config = {
1578
+ state: "failed",
1579
+ changed: false,
1580
+ action: "failed",
1581
+ message: coordinated.status === "busy"
1582
+ ? `Another process is writing Codex configuration right now (waited ${coordinated.waitedMs}ms). Retry shortly.`
1583
+ : `Codex configuration was not restored: ${coordinated.message}`,
1584
+ };
1585
+ } else {
1586
+ config = coordinated.value.config;
1587
+ transitionReceipt = coordinated.value.receipt;
1588
+ }
1589
+ } else {
1590
+ // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path
1591
+ // they have always had; restore is the escape hatch and must not strand
1592
+ // them. The plain re-read still honors an intervening re-enable.
1593
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
1594
+ return desiredEnabledRestoreSkip();
1595
+ }
1596
+ config = restoreCodexConfigInline();
1597
+ }
1598
+
1599
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
1600
+ const outcome = await runCodexHistoryJob({
1601
+ ...resolveCodexHistoryJobTarget(),
1602
+ ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
1603
+ operation: deriveCodexHistoryOperation({ direction: "restore", resumeHistory: true, legacyMode: false }),
1604
+ });
1605
+ if (transitionReceipt) {
1606
+ resolveCodexHistoryTransition(transitionReceipt, outcome);
1607
+ }
1608
+ const history: CodexRestoreHistoryResult = outcome.kind === "converged"
1609
+ ? {
1610
+ state: "ok", changed: outcome.rows > 0, rows: outcome.rows, files: outcome.files, ejectedRows: 0,
1611
+ message: outcome.rows > 0
1612
+ ? `Resume history restored from opencodex backup (${outcome.rows} thread(s)).`
1613
+ : "Codex resume history was already native.",
1614
+ }
1615
+ : outcome.kind === "skipped"
1616
+ ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "Codex resume history was skipped." }
1617
+ : outcome.kind === "blocked" && (outcome.reason === "desired_disabled" || outcome.reason === "desired_enabled")
1618
+ ? {
1619
+ state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0,
1620
+ message: outcome.reason === "desired_disabled"
1621
+ ? "Codex integration was disabled; history restoration was skipped."
1622
+ : "Codex integration was enabled; history restoration was skipped.",
1623
+ }
1624
+ : outcome.kind === "blocked" || outcome.kind === "failed"
1625
+ ? failedHistoryRestoreFromOutcome(outcome)
1626
+ : failedHistoryRestore();
1627
+ const base = catalog.removed > 0
1628
+ ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
1629
+ : config.message;
1630
+ const success = config.state !== "failed"
1631
+ && catalog.state !== "failed"
1632
+ && history.state !== "failed";
1633
+ return {
1634
+ success,
1635
+ message: `${base}${history.state === "failed" ? ` ⚠️ ${history.message}` : ""}`,
1636
+ artifacts: { config, catalog, history },
1637
+ };
1638
+ }
1639
+
1640
+ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateDesiredState?: boolean } = {}): CodexNativeRestoreResult {
1641
+ const activeProvider = currentExternalCodexModelProvider();
1642
+ if (activeProvider) {
1643
+ removeJournal();
1644
+ return externalProviderRestoreResult(activeProvider);
1645
+ }
1646
+ if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
1647
+ return desiredEnabledRestoreSkip();
1648
+ }
1649
+ // Captured before the config half: a successful journal restore DELETES the journal, and
1650
+ // restoring the config can drop `model_catalog_json`. Either one would hide the routed
1651
+ // catalog we actually wrote (#1798).
1652
+ const journaledCatalogPath = journaledInjectedCatalogPath();
1653
+ const config = restoreCodexConfigInline();
1654
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
775
1655
  // Design B (loopback) steady state: threads are already tagged openai, so prove the
776
1656
  // no-op with a readonly probe instead of write-opening a DB the Codex app may hold
777
1657
  // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop).
@@ -779,21 +1659,62 @@ export function restoreNativeCodex(): { success: boolean; message: string } {
779
1659
  let skipWhenProvablyNoop = false;
780
1660
  try {
781
1661
  skipWhenProvablyNoop = !shouldInjectApiAuthHeader(loadConfig());
782
- } catch { /* unreadable config: keep the conservative write-open restore */ }
783
- const history = syncCodexHistoryProvider("openai", undefined, undefined, { skipWhenProvablyNoop });
784
- const msg = cat.removed > 0
785
- ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
786
- : cfg.message;
787
- const historyMsg = history.failed
788
- ? ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history DB. Close the Codex app/IDE and run 'ocx stop' again; until then routed threads stay hidden in the native app.`
789
- : history.rows > 0
790
- ? ` Resume history restored from opencodex backup (${history.rows} thread(s)).`
791
- : history.ejectedRows
792
- ? ` ${history.ejectedRows} opencodex history thread(s) were ejected to openai so native Codex can resume them.`
793
- : "";
794
- return { success: cfg.success, message: `${msg}${historyMsg}` };
1662
+ } catch {
1663
+ /* unreadable config: keep the conservative write-open restore */
1664
+ }
1665
+ // `skipHistory` is how the async wrapper takes this work for itself: the
1666
+ // native files come down here, and history runs in the Worker under H.
1667
+ const rawHistory = options.skipHistory
1668
+ ? { rows: 0, files: 0 }
1669
+ : syncCodexHistoryProvider("openai", undefined, undefined, {
1670
+ skipWhenProvablyNoop,
1671
+ });
1672
+ const history: CodexRestoreHistoryResult = options.skipHistory
1673
+ ? { state: "skipped", changed: false, rows: 0, files: 0, ejectedRows: 0, message: "History restoration runs asynchronously." }
1674
+ : rawHistory.failed
1675
+ ? failedHistoryRestore(rawHistory.failureReason)
1676
+ : {
1677
+ state: "ok",
1678
+ changed: rawHistory.rows > 0 || (rawHistory.ejectedRows ?? 0) > 0,
1679
+ rows: rawHistory.rows,
1680
+ files: rawHistory.files,
1681
+ ejectedRows: rawHistory.ejectedRows ?? 0,
1682
+ message: rawHistory.rows > 0
1683
+ ? `Resume history restored from opencodex backup (${rawHistory.rows} thread(s)).`
1684
+ : "Codex resume history was already native.",
1685
+ };
1686
+ const message = catalog.removed > 0
1687
+ ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
1688
+ : config.message;
1689
+ return {
1690
+ success: config.state !== "failed" && catalog.state !== "failed" && history.state !== "failed",
1691
+ message,
1692
+ artifacts: { config, catalog, history },
1693
+ };
795
1694
  }
796
1695
 
797
1696
  export function getCodexConfigPath(): string {
798
1697
  return CODEX_CONFIG_PATH;
799
1698
  }
1699
+
1700
+ /**
1701
+ * Frame one failed apply history job honestly.
1702
+ *
1703
+ * A genuine lock keeps the established deferred/SKIPPED wording; any other
1704
+ * reason names itself instead of blaming the Codex app/IDE.
1705
+ */
1706
+ export function formatApplyHistoryFailure(outcome: CodexHistoryJobOutcome, legacyMode: boolean): string {
1707
+ // A busy database is a deferral no matter which half observed it: the lock
1708
+ // contended (blocked/busy), or the worker acquired the lock and then found
1709
+ // SQLite busy (failed with a busy history reason). Only those keep the
1710
+ // deferred headline; every other failure is a real "NOT changed".
1711
+ const busy =
1712
+ (outcome.kind === "blocked" && outcome.reason === "busy") ||
1713
+ (outcome.kind === "failed" && outcome.historyFailureReason === "busy");
1714
+ const headline = legacyMode
1715
+ ? "Codex resume history sync SKIPPED"
1716
+ : busy
1717
+ ? "Codex resume history migration deferred"
1718
+ : "Codex resume history NOT changed";
1719
+ return ` ⚠️ ${headline}: ${describeHistoryJobFailure(outcome, "apply", legacyMode)}\n`;
1720
+ }