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