@bitkyc08/opencodex 2.80.0 → 2.82.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 (376) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +166 -73
  3. package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
  4. package/gui/dist/assets/Tray-BqHF5Trd.js +1 -0
  5. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  6. package/gui/dist/assets/index-D3FBCNiP.js +94 -0
  7. package/gui/dist/assets/quota-summary-DTBC8n7-.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/claude-green.svg +1 -0
  10. package/package.json +3 -2
  11. package/src/adapters/anthropic-model-contract.ts +8 -2
  12. package/src/adapters/anthropic.ts +8 -3
  13. package/src/adapters/codebuddy/mcp-server.ts +2 -25
  14. package/src/adapters/codebuddy/tool-bridge.ts +1 -7
  15. package/src/adapters/coding-agent/mcp-server.ts +33 -0
  16. package/src/adapters/coding-agent/protocol.ts +122 -17
  17. package/src/adapters/coding-agent/tool-catalog.ts +75 -0
  18. package/src/adapters/coding-agent/turn.ts +101 -44
  19. package/src/adapters/cursor/catalog.ts +8 -0
  20. package/src/adapters/cursor/effort-map.ts +2 -0
  21. package/src/adapters/devin/live-models.ts +2 -0
  22. package/src/adapters/kiro-retry.ts +9 -4
  23. package/src/adapters/openai-chat/messages.ts +7 -6
  24. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  25. package/src/adapters/openai-responses/passthrough.ts +9 -4
  26. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  27. package/src/adapters/physical-send.ts +8 -5
  28. package/src/adapters/qoder/adapter.ts +41 -6
  29. package/src/adapters/qoder/scaffold-guard.ts +2 -3
  30. package/src/adapters/zed.ts +16 -1
  31. package/src/claude/agents-inject.ts +14 -3
  32. package/src/claude/context-windows.ts +19 -17
  33. package/src/claude/desktop-3p.ts +88 -12
  34. package/src/claude/desktop-profile.ts +2 -1
  35. package/src/claude/gateway-cache.ts +18 -1
  36. package/src/claude/inbound-cache-stabilize.ts +4 -9
  37. package/src/claude/inbound.ts +78 -3
  38. package/src/claude/intercept/cli-catalog.ts +3 -0
  39. package/src/claude/intercept/model-bindings.ts +1 -0
  40. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  41. package/src/claude/intercept/picker-listener.ts +71 -4
  42. package/src/claude/intercept/picker-models.ts +14 -6
  43. package/src/claude/intercept/settings.ts +3 -0
  44. package/src/claude/long-context.ts +66 -0
  45. package/src/claude/model-info.ts +19 -16
  46. package/src/claude/outbound.ts +94 -9
  47. package/src/claude/subagent-model.ts +40 -11
  48. package/src/cli/account-anthropic-threshold.ts +14 -5
  49. package/src/cli/account-api.ts +3 -1
  50. package/src/cli/account-extended.ts +13 -9
  51. package/src/cli/account-policy-dto.ts +1 -1
  52. package/src/cli/account-policy.ts +13 -4
  53. package/src/cli/account.ts +3 -3
  54. package/src/cli/agent.ts +9 -5
  55. package/src/cli/aside-profile-recovery.ts +33 -0
  56. package/src/cli/capabilities-accounts.ts +2 -2
  57. package/src/cli/capabilities-agents-routing.ts +24 -0
  58. package/src/cli/capabilities-base.ts +5 -5
  59. package/src/cli/capabilities-integrations.ts +18 -2
  60. package/src/cli/cli-path-diagnostics.ts +132 -0
  61. package/src/cli/codex-shim-autorestore.ts +3 -0
  62. package/src/cli/desktop-runtime-guidance.ts +65 -0
  63. package/src/cli/dispatch.ts +15 -1
  64. package/src/cli/doctor.ts +16 -3
  65. package/src/cli/help.ts +4 -1
  66. package/src/cli/index.ts +25 -14
  67. package/src/cli/integration-aside-sync.ts +2 -1
  68. package/src/cli/integration-plan-dto.ts +1 -0
  69. package/src/cli/integration-preview.ts +8 -1
  70. package/src/cli/integrations.ts +48 -6
  71. package/src/cli/message-args.ts +34 -0
  72. package/src/cli/message-command.ts +43 -0
  73. package/src/cli/message-runtime.ts +6 -0
  74. package/src/cli/observe.ts +3 -1
  75. package/src/cli/provider.ts +29 -6
  76. package/src/cli/registry.ts +24 -2
  77. package/src/cli/resolve.ts +25 -2
  78. package/src/cli/root.ts +4 -2
  79. package/src/cli/status.ts +64 -12
  80. package/src/cli/update-restart-child.ts +4 -2
  81. package/src/cli/update-restart-home.ts +28 -10
  82. package/src/cli/update-restart-service-record.ts +116 -0
  83. package/src/cli/update-restart-supervision.ts +96 -0
  84. package/src/cli/update-restart.ts +59 -13
  85. package/src/cli/v2.ts +27 -20
  86. package/src/cli/version-skew-notice.ts +77 -0
  87. package/src/clients/config-export/commandcode.ts +131 -0
  88. package/src/clients/config-export/contracts.ts +12 -0
  89. package/src/clients/config-export.ts +64 -1
  90. package/src/clients/lazycodex.ts +1 -1
  91. package/src/clients/omo-role-models.ts +32 -7
  92. package/src/codex/auth-api/account-list.ts +11 -3
  93. package/src/codex/auth-api/account-selection.ts +107 -0
  94. package/src/codex/auth-api/main-account-probe.ts +25 -8
  95. package/src/codex/auth-api/pool-mode-gate.ts +33 -9
  96. package/src/codex/auth-api/pool-quota-probe.ts +15 -1
  97. package/src/codex/auth-api/routes.ts +11 -49
  98. package/src/codex/auth-context.ts +20 -1
  99. package/src/codex/autostart-health.ts +12 -3
  100. package/src/codex/catalog/auto-review.ts +13 -6
  101. package/src/codex/catalog/build-entries.ts +33 -7
  102. package/src/codex/catalog/bundled.ts +155 -26
  103. package/src/codex/catalog/combo-member.ts +3 -2
  104. package/src/codex/catalog/control-plane.ts +45 -0
  105. package/src/codex/catalog/effort.ts +37 -6
  106. package/src/codex/catalog/gather-capture.ts +10 -3
  107. package/src/codex/catalog/model-hints.ts +15 -8
  108. package/src/codex/catalog/model-visibility.ts +3 -1
  109. package/src/codex/catalog/operator-tiers.ts +38 -0
  110. package/src/codex/catalog/parsing.ts +2 -1
  111. package/src/codex/catalog/provider-models.ts +58 -44
  112. package/src/codex/catalog/retained-sync.ts +7 -5
  113. package/src/codex/catalog/routed-gather.ts +15 -1
  114. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  115. package/src/codex/catalog-auto-refresh.ts +19 -0
  116. package/src/codex/catalog.ts +1 -1
  117. package/src/codex/codex-home-owner.ts +3 -0
  118. package/src/codex/config-write-lock.ts +333 -0
  119. package/src/codex/control-plane-models.ts +6 -0
  120. package/src/codex/convergence.ts +2 -1
  121. package/src/codex/features.ts +257 -200
  122. package/src/codex/inject/config-write-section.ts +34 -0
  123. package/src/codex/inject/multi-agent-v2.ts +14 -6
  124. package/src/codex/inject/remove.ts +36 -11
  125. package/src/codex/inject/restore.ts +85 -20
  126. package/src/codex/inject-coordination.ts +15 -6
  127. package/src/codex/inject.ts +62 -20
  128. package/src/codex/journal.ts +62 -17
  129. package/src/codex/main-account-cache.ts +43 -0
  130. package/src/codex/paths.ts +2 -0
  131. package/src/codex/prepared-home.ts +18 -0
  132. package/src/codex/prompt-journal.ts +25 -12
  133. package/src/codex/prompt-layers.ts +49 -26
  134. package/src/codex/prompt-lock-claim.ts +134 -0
  135. package/src/codex/prompt-lock-evidence.ts +26 -0
  136. package/src/codex/prompt-lock-io.ts +16 -0
  137. package/src/codex/prompt-lock-owner.ts +130 -0
  138. package/src/codex/prompt-lock.ts +84 -53
  139. package/src/codex/routing/failure-window.ts +146 -0
  140. package/src/codex/routing/selection.ts +4 -1
  141. package/src/codex/routing.ts +13 -17
  142. package/src/codex/runtime.ts +106 -18
  143. package/src/codex/shim.ts +45 -19
  144. package/src/combos/jev-decision-contract.ts +25 -2
  145. package/src/combos/jev-service-exchange.ts +281 -0
  146. package/src/combos/jev.ts +34 -219
  147. package/src/combos/types.ts +1 -1
  148. package/src/config/atomic-write.ts +1 -1
  149. package/src/config/codex-ws-reuse-setting.ts +10 -0
  150. package/src/config/diagnostics.ts +27 -16
  151. package/src/config/live-reconcile.ts +17 -0
  152. package/src/config/load-degrade.ts +42 -0
  153. package/src/config/rebase-provenance.ts +11 -0
  154. package/src/config/schema/anthropic-account-pool.ts +143 -0
  155. package/src/config/schema/config-schema.ts +11 -1
  156. package/src/config/schema/leaf-validators.ts +4 -0
  157. package/src/config.ts +2 -0
  158. package/src/generated/compatibility-version.json +566 -298
  159. package/src/generated/model-metadata.ts +7 -6
  160. package/src/grok/inject.ts +50 -4
  161. package/src/grok/sync.ts +2 -1
  162. package/src/images/loop.ts +30 -11
  163. package/src/integrations/aside-profiles.ts +10 -3
  164. package/src/integrations/catalog-refresh.ts +19 -3
  165. package/src/integrations/mutation-plan.ts +7 -0
  166. package/src/integrations/registry.ts +8 -0
  167. package/src/integrations/state.ts +9 -2
  168. package/src/integrations/writer.ts +17 -5
  169. package/src/lib/bun-path-runtime.d.mts +32 -0
  170. package/src/lib/bun-path-runtime.mjs +95 -0
  171. package/src/lib/bun-runtime-preflight.ts +140 -0
  172. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  173. package/src/lib/desktop-cli-handoff.mjs +92 -0
  174. package/src/lib/desktop-cli-record.d.mts +29 -0
  175. package/src/lib/desktop-cli-record.mjs +102 -0
  176. package/src/lib/lab-live-route-production.ts +9 -1
  177. package/src/lib/request-execution-budget.ts +244 -58
  178. package/src/lib/spend-pool-alias-validation.ts +79 -0
  179. package/src/lib/spend-pool-continuity.ts +34 -0
  180. package/src/lib/spend-reservation-ledger.ts +412 -50
  181. package/src/lib/state-store-registrations.ts +20 -3
  182. package/src/lib/synced-state-location.ts +46 -6
  183. package/src/lib/test-home-guard.ts +87 -1
  184. package/src/lib/translator-budget.ts +17 -4
  185. package/src/lib/upstream-retry.ts +17 -5
  186. package/src/lib/windows-atomic-replace.ts +38 -16
  187. package/src/lib/windows-elevation.ts +6 -0
  188. package/src/lib/windows-secret-acl.ts +92 -25
  189. package/src/lib/winsw.ts +2 -3
  190. package/src/lib/workflow-budget.ts +53 -11
  191. package/src/messaging/budget.ts +45 -0
  192. package/src/messaging/discovery.ts +57 -0
  193. package/src/messaging/envelope.ts +49 -0
  194. package/src/messaging/input.ts +37 -0
  195. package/src/messaging/rpc.ts +196 -0
  196. package/src/messaging/send.ts +58 -0
  197. package/src/messaging/socket.ts +72 -0
  198. package/src/messaging/types.ts +29 -0
  199. package/src/oauth/anthropic-account-refusal.ts +90 -18
  200. package/src/oauth/anthropic-account-threshold.ts +8 -2
  201. package/src/oauth/anthropic-continuity.ts +15 -1
  202. package/src/oauth/anthropic-model-quota.ts +120 -48
  203. package/src/oauth/anthropic-model-routes.ts +11 -3
  204. package/src/oauth/anthropic-oauth-definitions.ts +98 -0
  205. package/src/oauth/anthropic-pool-config.ts +30 -0
  206. package/src/oauth/anthropic-rate-limit-policy.ts +64 -9
  207. package/src/oauth/anthropic-routing.ts +1202 -866
  208. package/src/oauth/anthropic-send-ownership.ts +41 -0
  209. package/src/oauth/anthropic.ts +4 -1
  210. package/src/oauth/chatgpt.ts +113 -41
  211. package/src/oauth/generic-account-failover.ts +3 -1
  212. package/src/oauth/health.ts +8 -6
  213. package/src/oauth/index.ts +96 -175
  214. package/src/oauth/kiro.ts +22 -9
  215. package/src/oauth/login-cli.ts +2 -0
  216. package/src/oauth/model-discovery-auth.ts +43 -0
  217. package/src/oauth/pool-kernel.ts +11 -5
  218. package/src/oauth/pool-settings-capability.ts +5 -3
  219. package/src/oauth/store-anthropic-instance.ts +87 -0
  220. package/src/oauth/store.ts +68 -10
  221. package/src/oauth/token-guardian.ts +2 -0
  222. package/src/oauth/types.ts +2 -0
  223. package/src/protocols/plan-snapshot.ts +5 -0
  224. package/src/protocols/settings.ts +20 -5
  225. package/src/providers/anthropic-instance-id.ts +47 -0
  226. package/src/providers/anthropic-instance.ts +48 -0
  227. package/src/providers/anthropic-reset-grant-ledger.ts +11 -1
  228. package/src/providers/azure-model-metadata.ts +115 -0
  229. package/src/providers/derive.ts +23 -0
  230. package/src/providers/kiro-models.ts +3 -0
  231. package/src/providers/label.ts +1 -1
  232. package/src/providers/model-rename-fields.ts +2 -0
  233. package/src/providers/quota/account-cache.ts +37 -11
  234. package/src/providers/quota/anthropic-account-quota.ts +93 -0
  235. package/src/providers/quota/anthropic-cooldown-recovery.ts +222 -111
  236. package/src/providers/quota/report-cache.ts +24 -1
  237. package/src/providers/quota/vendor-probes-key.ts +127 -17
  238. package/src/providers/quota/vendor-probes-oauth.ts +46 -14
  239. package/src/providers/quota.ts +23 -33
  240. package/src/providers/registry/entries-core.ts +35 -30
  241. package/src/providers/registry/model-ids.ts +1 -0
  242. package/src/providers/registry/model-seeds.ts +7 -4
  243. package/src/providers/registry/types.ts +3 -0
  244. package/src/providers/registry.ts +3 -3
  245. package/src/responses/reasoning-envelope.ts +29 -1
  246. package/src/responses/reasoning-replay-cache.ts +17 -1
  247. package/src/responses/spill-store.ts +20 -3
  248. package/src/responses/state/metrics.ts +95 -0
  249. package/src/responses/state/snapshot-codec.ts +6 -0
  250. package/src/responses/state/spill-failure.ts +12 -0
  251. package/src/responses/state/spill-queue.ts +13 -5
  252. package/src/responses/state/unforced-store-false.ts +55 -0
  253. package/src/responses/state.ts +68 -76
  254. package/src/responses/task-input.ts +17 -0
  255. package/src/router.ts +28 -4
  256. package/src/routing/compatibility/assemble.ts +1 -1
  257. package/src/routing/quota.ts +7 -3
  258. package/src/server/auth-cors.ts +5 -0
  259. package/src/server/background-lifecycle.ts +27 -0
  260. package/src/server/chat-completions.ts +7 -1
  261. package/src/server/chat-native.ts +64 -28
  262. package/src/server/claude-messages.ts +59 -8
  263. package/src/server/gui-static.ts +38 -4
  264. package/src/server/inbound-body-admission.ts +1 -0
  265. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  266. package/src/server/index/serve-options.ts +10 -2
  267. package/src/server/index/spend-ledger-lifecycle.ts +12 -5
  268. package/src/server/index/startup-warnings.ts +19 -0
  269. package/src/server/index.ts +5 -2
  270. package/src/server/inference/context.ts +1 -1
  271. package/src/server/lifecycle.ts +1 -1
  272. package/src/server/management/agent-settings-routes.ts +123 -38
  273. package/src/server/management/anthropic-account-threshold.ts +12 -9
  274. package/src/server/management/anthropic-pool-settings.ts +72 -0
  275. package/src/server/management/anthropic-reset-grant-routes.ts +98 -39
  276. package/src/server/management/codex-agent-role-routes.ts +4 -2
  277. package/src/server/management/codex-prompt-routes.ts +1 -0
  278. package/src/server/management/codex-role-auto-assign.ts +13 -2
  279. package/src/server/management/config-routes.ts +85 -14
  280. package/src/server/management/context.ts +2 -2
  281. package/src/server/management/decision-routes.ts +2 -1
  282. package/src/server/management/integration-routes.ts +25 -2
  283. package/src/server/management/oauth-account-routes.ts +79 -98
  284. package/src/server/management/provider-instance-ownership.ts +44 -0
  285. package/src/server/management/provider-routes.ts +21 -28
  286. package/src/server/management/route-registry.ts +3 -1
  287. package/src/server/management/routing-profile-routes.ts +4 -4
  288. package/src/server/management/shared.ts +3 -3
  289. package/src/server/management/system-restart.ts +44 -14
  290. package/src/server/management/system-routes.ts +24 -2
  291. package/src/server/management/vision-sidecar-options.ts +9 -6
  292. package/src/server/management/web-search-sidecar-options.ts +51 -2
  293. package/src/server/management-auth.ts +6 -0
  294. package/src/server/messages-native-eligibility.ts +3 -2
  295. package/src/server/messages-native-oauth.ts +91 -45
  296. package/src/server/messages-native-selector.ts +39 -0
  297. package/src/server/messages-native.ts +133 -38
  298. package/src/server/messages-response-headers.ts +14 -0
  299. package/src/server/request-log-conversation.ts +48 -5
  300. package/src/server/request-log.ts +36 -2
  301. package/src/server/responses/adapter-continuation.ts +31 -19
  302. package/src/server/responses/adapter-dispatch.ts +75 -24
  303. package/src/server/responses/antigravity-validation-refusal.ts +20 -6
  304. package/src/server/responses/codex-ws-exchange.ts +17 -2
  305. package/src/server/responses/codex-ws-pool.ts +100 -13
  306. package/src/server/responses/codex-ws-wire.ts +9 -0
  307. package/src/server/responses/combo-requested-effort.ts +3 -1
  308. package/src/server/responses/compact.ts +25 -8
  309. package/src/server/responses/compaction-recovery.ts +1 -1
  310. package/src/server/responses/core-codex-account.ts +22 -1
  311. package/src/server/responses/core-combo-native.ts +24 -4
  312. package/src/server/responses/core-combo.ts +38 -25
  313. package/src/server/responses/core-lifetime.ts +3 -36
  314. package/src/server/responses/core-normalize.ts +1 -0
  315. package/src/server/responses/core-options.ts +8 -1
  316. package/src/server/responses/core-replay.ts +78 -0
  317. package/src/server/responses/core.ts +3 -3
  318. package/src/server/responses/fetch-helpers.ts +26 -3
  319. package/src/server/responses/passthrough-delivery.ts +30 -4
  320. package/src/server/responses/passthrough-dispatch.ts +39 -9
  321. package/src/server/responses/request-prepare.ts +41 -5
  322. package/src/server/responses/request-send-budget.ts +150 -50
  323. package/src/server/responses/request-spend.ts +325 -33
  324. package/src/server/responses/request-transport.ts +132 -52
  325. package/src/server/responses/response-effects.ts +1 -0
  326. package/src/server/responses/run-turn-execution.ts +13 -3
  327. package/src/server/responses/sidecar-execution.ts +113 -22
  328. package/src/server/responses/sidecar-send-budget.ts +58 -0
  329. package/src/server/responses/ws-upstream.ts +19 -6
  330. package/src/server/responses-undeclared-tool-guard.ts +5 -1
  331. package/src/server/startup-health-cache.ts +2 -1
  332. package/src/server/workflow-refusal.ts +33 -0
  333. package/src/service/cli.ts +20 -4
  334. package/src/service/desktop-command-guard.ts +32 -0
  335. package/src/service/desktop-startup.ts +66 -40
  336. package/src/service/desktop-supervision.d.mts +26 -0
  337. package/src/service/desktop-supervision.mjs +140 -0
  338. package/src/service/managing-cli.ts +1 -0
  339. package/src/service/orchestration.ts +46 -20
  340. package/src/service/ownership-mutation-lease.d.mts +1 -1
  341. package/src/service/ownership-mutation-lease.mjs +5 -7
  342. package/src/service/repair.ts +25 -9
  343. package/src/service/state.ts +4 -3
  344. package/src/service/systemd.ts +17 -8
  345. package/src/service/windows-ops.ts +61 -22
  346. package/src/sidecar/anthropic-binding.ts +79 -0
  347. package/src/sidecar/auth.ts +76 -2
  348. package/src/storage/policy-job.ts +5 -1
  349. package/src/storage/policy-worker.ts +3 -1
  350. package/src/storage/policy.ts +9 -0
  351. package/src/types/anthropic-account-pool.ts +33 -0
  352. package/src/types/config.ts +33 -28
  353. package/src/types/provider.ts +5 -0
  354. package/src/types/request.ts +5 -0
  355. package/src/types/tools.ts +7 -0
  356. package/src/types/wire.ts +1 -0
  357. package/src/types.ts +4 -0
  358. package/src/update/index.ts +57 -9
  359. package/src/update/restart-ownership.ts +10 -3
  360. package/src/update/runtime-ownership.d.mts +6 -0
  361. package/src/update/runtime-ownership.mjs +23 -7
  362. package/src/usage/cost.ts +3 -2
  363. package/src/usage/expected-prices.ts +66 -14
  364. package/src/vision/anthropic-describe.ts +16 -3
  365. package/src/vision/index.ts +3 -0
  366. package/src/vision/plan.ts +19 -6
  367. package/src/web-search/alpha-search.ts +7 -1
  368. package/src/web-search/anthropic-executor.ts +16 -4
  369. package/src/web-search/index.ts +7 -3
  370. package/src/web-search/loop.ts +32 -7
  371. package/src/web-search/passthrough-bridge.ts +4 -1
  372. package/src/web-search/sidecar-providers.ts +4 -3
  373. package/gui/dist/assets/App-MbDVfUeC.js +0 -54
  374. package/gui/dist/assets/Tray-CzkxK82A.js +0 -1
  375. package/gui/dist/assets/index-UJt6npXk.js +0 -94
  376. package/gui/dist/assets/quota-summary-DU3sD6Z-.js +0 -1
@@ -0,0 +1,34 @@
1
+ import type { AtomicWriteHooks } from "../../config/atomic-write";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { withConfigMutationLockSync } from "../../config";
4
+ import { ConfigWriteLockRefusal, assertConfigWriteDestination, publishConfigWriteTarget, watchConfigWriteTargets, withConfigWriteLockHeld, type LockHandle } from "../config-write-lock";
5
+ import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "../paths";
6
+ import { getCodexHome } from "../paths";
7
+ import { codexHomeIsAbsent } from "../codex-home-owner";
8
+ import { JOURNAL_PATH, removeJournal } from "../journal";
9
+ import { externalCodexModelProvider } from "./config-toml";
10
+
11
+ export function beginCodexWriteSection(held: LockHandle): void {
12
+ assertConfigWriteDestination(CODEX_CONFIG_PATH, held);
13
+ watchConfigWriteTargets(held, [CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, JOURNAL_PATH]);
14
+ }
15
+ export function publishCodexArtifact<T>(path: string, held: LockHandle | undefined, run: (destination: string, hooks: AtomicWriteHooks) => T): T {
16
+ if (!held) throw new Error("Codex artifact publication requires a held configuration lock");
17
+ return publishConfigWriteTarget(CODEX_CONFIG_PATH, held, path, run);
18
+ }
19
+ /** Courtesy cleanup re-reads provider ownership inside the same config section. */
20
+ export function cleanExternalProviderJournal(beforeClientWrite?: () => void): void {
21
+ if (codexHomeIsAbsent(getCodexHome())) return;
22
+ const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, held => {
23
+ beginCodexWriteSection(held);
24
+ const cleanup = () => {
25
+ beforeClientWrite?.();
26
+ if (existsSync(CODEX_CONFIG_PATH) && externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8"))) {
27
+ publishCodexArtifact(JOURNAL_PATH, held, (_path, hooks) => removeJournal(hooks));
28
+ }
29
+ };
30
+ if (beforeClientWrite) withConfigMutationLockSync(cleanup);
31
+ else cleanup();
32
+ });
33
+ if (!locked.ok) throw new ConfigWriteLockRefusal(locked);
34
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import type { OcxConfig } from "../../types";
3
+ import { ConfigWriteDestinationChanged, type LockHandle } from "../config-write-lock";
3
4
  import { CODEX_CONFIG_PATH } from "../paths";
4
5
 
5
6
  /**
@@ -18,11 +19,11 @@ export type InjectedV1SurfaceReconcile =
18
19
  | { ok: true; content: string; changed: boolean }
19
20
  | { ok: false; message: string };
20
21
 
21
- let toggleForTests: ((enabled: boolean) => void) | undefined;
22
+ let toggleForTests: ((enabled: boolean, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void) => void) | undefined;
22
23
 
23
24
  /** Test seam: substitute the native `codex features` toggle so no Codex runtime is required. */
24
25
  export function setCodexMultiAgentV2ToggleForTests(
25
- toggle: ((enabled: boolean) => void) | undefined,
26
+ toggle: ((enabled: boolean, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void) => void) | undefined,
26
27
  ): void {
27
28
  toggleForTests = toggle;
28
29
  }
@@ -43,7 +44,11 @@ export interface PreparedV1SurfaceReconcile {
43
44
  * only — `run()` re-checks the flag on the bytes present under the lock.
44
45
  */
45
46
  readonly enabledAtPrepare: boolean;
46
- run(): InjectedV1SurfaceReconcile;
47
+ /**
48
+ * `heldConfigWriteLock` is the injector's own held write-lock handle: the
49
+ * transition would otherwise re-acquire the same file and refuse itself.
50
+ */
51
+ run(heldConfigWriteLock?: LockHandle): InjectedV1SurfaceReconcile;
47
52
  }
48
53
 
49
54
  /**
@@ -66,19 +71,22 @@ export async function prepareInjectedV1SurfaceReconcile(
66
71
  let toggle = toggleForTests;
67
72
  if (!toggle) {
68
73
  const { runCodexFeaturesCommand } = await import("../../cli/v2");
69
- toggle = enabled => runCodexFeaturesCommand(enabled ? "enable" : "disable");
74
+ toggle = (enabled, env, validate) => runCodexFeaturesCommand(enabled ? "enable" : "disable", "multi_agent_v2", env, validate);
70
75
  }
71
76
  const resolvedToggle = toggle;
72
77
  return {
73
78
  enabledAtPrepare,
74
- run() {
79
+ run(heldConfigWriteLock) {
75
80
  // Decide on the bytes present NOW, under the lock — the prepare-time
76
81
  // answer is stale the moment another writer could have touched the file.
77
82
  if (!isMultiAgentV2Enabled()) {
78
83
  return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8"), changed: false };
79
84
  }
80
- const transition = transitionMultiAgentV2(false, resolvedToggle);
85
+ const transition = transitionMultiAgentV2(false, resolvedToggle, {
86
+ ...(heldConfigWriteLock !== undefined ? { heldConfigWriteLock } : {}),
87
+ });
81
88
  if (!transition.ok) {
89
+ if (transition.retryable === false) throw new ConfigWriteDestinationChanged(transition.error);
82
90
  return {
83
91
  ok: false,
84
92
  message: `Codex config injection refused: could not reconcile the v1 surface with the global multi_agent_v2 feature: ${transition.error}.`,
@@ -1,6 +1,9 @@
1
+ import { beginCodexWriteSection, publishCodexArtifact } from "./config-write-section";
1
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
3
  import { normalizeStructuralWhitespace, rootAssignmentKey, rootSourceLines, sourceAssignment, sourceText } from "../toml-source-lines";
3
4
  import { atomicWriteFile } from "../../config";
5
+ import { ConfigWriteLockRefusal, configWriteLockFailureMessage, withConfigWriteLockHeld } from "../config-write-lock";
6
+ import type { LockHandle } from "../config-write-lock";
4
7
  import {
5
8
  REALTIME_WS_BASE_URL_KEY,
6
9
  hasInjectedOpenaiBaseUrl,
@@ -15,6 +18,8 @@ import {
15
18
  journaledReplacedRootWebSearch,
16
19
  } from "../journal";
17
20
  import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "../paths";
21
+ import { getCodexHome } from "../paths";
22
+ import { codexHomeIsAbsent } from "../codex-home-owner";
18
23
  import { transformManagedSubagentDefaults } from "../subagent-defaults";
19
24
  import {
20
25
  applyEol,
@@ -45,14 +50,22 @@ export function readOcxProviderTableBlock(): string | null {
45
50
  * writes leaves a fully native config, which is the direction this whole change is trying
46
51
  * to reach anyway.
47
52
  */
48
- export function retainOcxProviderTableOnDisk(block: string): string[] | null {
49
- if (!existsSync(CODEX_CONFIG_PATH)) return null;
50
- const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
51
- const eol = dominantEol(rawContent);
52
- const content = applyEol(rawContent, "\n");
53
- const next = appendOcxProviderTableBlock(content, block);
54
- if (next !== content) atomicWriteFile(CODEX_CONFIG_PATH, applyEol(next, eol));
55
- return block.replace(/\n+$/, "").split("\n");
53
+ export function retainOcxProviderTableOnDisk(block: string, heldConfigWriteLock?: LockHandle): string[] | null {
54
+ // The read, the append, and the rename are one section under the shared
55
+ // write lock; an unlocked read+rename here could discard a concurrent
56
+ // scalar or projection write.
57
+ const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, heldConfigWriteLock, held => {
58
+ beginCodexWriteSection(held);
59
+ if (!existsSync(CODEX_CONFIG_PATH)) return null;
60
+ const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
61
+ const eol = dominantEol(rawContent);
62
+ const content = applyEol(rawContent, "\n");
63
+ const next = appendOcxProviderTableBlock(content, block);
64
+ if (next !== content) publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, applyEol(next, eol), undefined, hooks));
65
+ return block.replace(/\n+$/, "").split("\n");
66
+ });
67
+ if (!locked.ok) throw new ConfigWriteLockRefusal(locked);
68
+ return locked.value;
56
69
  }
57
70
 
58
71
  interface StripOpencodexConfigResult {
@@ -162,9 +175,12 @@ export type RemoveCodexConfigHistoryDisposition =
162
175
  export interface RemoveCodexConfigOptions {
163
176
  preserveProfile?: boolean;
164
177
  historyDisposition?: RemoveCodexConfigHistoryDisposition;
178
+ /** Run under a config write lock this process already holds (restore's single section). */
179
+ heldConfigWriteLock?: LockHandle;
165
180
  }
166
181
 
167
182
  export interface RemoveCodexConfigResult {
183
+ retryable?: boolean;
168
184
  success: boolean;
169
185
  message: string;
170
186
  /** The exact lines left on disk when the disposition was `stand-down-retain`. */
@@ -174,6 +190,10 @@ export interface RemoveCodexConfigResult {
174
190
  export function removeCodexConfig(
175
191
  options: RemoveCodexConfigOptions = {},
176
192
  ): RemoveCodexConfigResult {
193
+ if (codexHomeIsAbsent(getCodexHome())) return {
194
+ success: true,
195
+ message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
196
+ };
177
197
  const historyDisposition = options.historyDisposition ?? "refuse-on-any";
178
198
  const historyError = preflightCodexHistoryInjection(false, false);
179
199
  // The preflight answers "may I rewrite conversation history?". Routing removal is a
@@ -184,9 +204,11 @@ export function removeCodexConfig(
184
204
  if (historyError && !(historyDisposition !== "refuse-on-any" && historyError === HISTORY_RELABEL_STANDS_DOWN)) {
185
205
  return { success: false, message: `Codex configuration preserved: ${historyError}. Native writer coordination is required.` };
186
206
  }
207
+ const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, options.heldConfigWriteLock, held => {
208
+ beginCodexWriteSection(held);
187
209
  if (!existsSync(CODEX_CONFIG_PATH)) {
188
210
  if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
189
- unlinkSync(CODEX_PROFILE_PATH);
211
+ publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); });
190
212
  return {
191
213
  success: true,
192
214
  message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
@@ -223,10 +245,10 @@ export function removeCodexConfig(
223
245
  ? stripped.content
224
246
  : appendOcxProviderTableBlock(stripped.content, retainedBlock);
225
247
  if (had || finalContent !== content) {
226
- atomicWriteFile(CODEX_CONFIG_PATH, applyEol(finalContent, eol));
248
+ publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, applyEol(finalContent, eol), undefined, hooks));
227
249
  }
228
250
  if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
229
- unlinkSync(CODEX_PROFILE_PATH);
251
+ publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); });
230
252
  const retainedNote = retainedBlock === null
231
253
  ? ""
232
254
  : " Kept [model_providers.opencodex] so conversations already tagged opencodex still open;"
@@ -250,4 +272,7 @@ export function removeCodexConfig(
250
272
  message: removedMessage,
251
273
  ...(retainedBlock === null ? {} : { retainedProviderTable: retainedBlock.replace(/\n+$/, "").split("\n") }),
252
274
  };
275
+ });
276
+ if (!locked.ok) return { success: false, retryable: locked.error === "locked", message: configWriteLockFailureMessage(locked) };
277
+ return locked.value;
253
278
  }
@@ -1,4 +1,5 @@
1
- import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "../codex-home-owner";
1
+ import { beginCodexWriteSection, cleanExternalProviderJournal, publishCodexArtifact } from "./config-write-section";
2
+ import { assertCodexHomeOwner, codexHomeIsAbsent, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "../codex-home-owner";
2
3
  import { loadConfig } from "../../config";
3
4
  import { shouldSyncCodexOnStart } from "../desired-state";
4
5
  import { siblingOfLivePort, siblingSkipMessage } from "../sibling-start";
@@ -26,7 +27,7 @@ import {
26
27
  } from "../user-identity";
27
28
  import {
28
29
  journaledInjectedCatalogPath,
29
- removeJournal,
30
+ JOURNAL_PATH,
30
31
  releaseJournalHomeBinding,
31
32
  restoreJournalState,
32
33
  } from "../journal";
@@ -44,10 +45,17 @@ import {
44
45
  type CodexHistoryJobOutcome,
45
46
  } from "../history-job";
46
47
  import {
48
+ CODEX_CONFIG_PATH,
47
49
  DEFAULT_CATALOG_PATH,
48
50
  getCodexHome,
49
51
  tomlString,
50
52
  } from "../paths";
53
+ import {
54
+ acquireConfigWriteLock,
55
+ releaseConfigWriteLock,
56
+ configWriteLockFailureMessage, withConfigWriteLockHeld,
57
+ } from "../config-write-lock";
58
+ import type { LockHandle } from "../config-write-lock";
51
59
  import { shouldInjectApiAuthHeader } from "../loopback-target";
52
60
  import { currentExternalCodexModelProvider } from "./config-toml";
53
61
  import {
@@ -110,6 +118,7 @@ export interface CodexRestoreConfigResult {
110
118
  | "failed";
111
119
  message: string;
112
120
  retained?: RetainedCodexProviderTable;
121
+ retryable?: boolean;
113
122
  }
114
123
 
115
124
  export interface CodexRestoreCatalogResult {
@@ -343,20 +352,45 @@ export interface RestoreConfigOptions {
343
352
  }
344
353
 
345
354
  /** The config/profile half of a native restore, reported as one artifact. */
346
- function restoreCodexConfigInline(kind = "sync", options: RestoreConfigOptions = {}): CodexRestoreConfigResult {
347
- assertCodexHomeOwner(getCodexHome());
348
- beforeRestoreConfigForTests?.(kind);
349
- assertCodexHomeOwner(getCodexHome());
350
- const preImages = captureCodexPreImages();
351
- const result = restoreCodexConfigInlineImpl(kind, options);
352
- if (result.state === "failed") {
353
- const compensated = restoreCodexPreImages(preImages);
354
- if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
355
+ function restoreCodexConfigInline(
356
+ kind = "sync",
357
+ options: RestoreConfigOptions = {},
358
+ heldConfigWriteLock?: LockHandle,
359
+ ): CodexRestoreConfigResult {
360
+ /*
361
+ * The whole config half — preimage capture, journal replay, removal, the
362
+ * provider-table re-attach, and failure compensation — is ONE section of the
363
+ * shared write lock. Splitting it let a foreign write land between the pieces
364
+ * and left preimage compensation replaying over bytes that writer committed.
365
+ */
366
+ const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, heldConfigWriteLock, (held) => {
367
+ beginCodexWriteSection(held);
368
+ assertCodexHomeOwner(getCodexHome());
369
+ beforeRestoreConfigForTests?.(kind);
370
+ assertCodexHomeOwner(getCodexHome());
371
+ const preImages = captureCodexPreImages();
372
+ const result = restoreCodexConfigInlineImpl(kind, options, held);
373
+ if (result.state === "failed") {
374
+ const compensated = restoreCodexPreImages(preImages, held);
375
+ if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
376
+ }
377
+ return result;
378
+ });
379
+ if (!locked.ok) {
380
+ // Nothing was captured or written — there is nothing to compensate.
381
+ return {
382
+ state: "failed", changed: false, action: "failed",
383
+ retryable: locked.error === "locked", message: configWriteLockFailureMessage(locked),
384
+ };
355
385
  }
356
- return result;
386
+ return locked.value;
357
387
  }
358
388
 
359
- function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOptions): CodexRestoreConfigResult {
389
+ function restoreCodexConfigInlineImpl(
390
+ kind: string,
391
+ options: RestoreConfigOptions,
392
+ heldConfigWriteLock: LockHandle,
393
+ ): CodexRestoreConfigResult {
360
394
  try {
361
395
  const disposition = resolveRestoreHistoryDisposition(options.removeProviderTable);
362
396
  if (disposition.kind === "refuse") {
@@ -374,8 +408,17 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
374
408
  // The one caller that must not capture is the explicit removal flag: it is the user
375
409
  // accepting that tagged conversations stop opening.
376
410
  const capturedBlock = options.removeProviderTable === true ? null : readOcxProviderTableBlock();
377
- const journal = restoreJournalState();
411
+ const journal = restoreJournalState({ heldConfigWriteLock });
378
412
  if (journal.ownershipRefusal) throw new CodexHomeOwnerRefusal(journal.ownershipRefusal);
413
+ if (journal.lockUnsafe) return { state: "failed", changed: false, action: "failed", retryable: false, message: journal.lockUnsafe };
414
+ if (journal.lockBusy) {
415
+ // Only reachable when this impl ran without a live held handle — the
416
+ // outer section takes the file lock before anything else runs.
417
+ return {
418
+ state: "failed", changed: false, action: "failed",
419
+ retryable: true, message: "Another process is writing Codex configuration right now. Retry shortly.",
420
+ };
421
+ }
379
422
  if (journal.unverified) {
380
423
  return {
381
424
  state: "failed", changed: false, action: "failed",
@@ -398,6 +441,7 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
398
441
  historyDisposition: disposition.kind === "stand-down"
399
442
  ? disposition.retainProviderTable ? "stand-down-retain" : "stand-down-remove"
400
443
  : "refuse-on-any",
444
+ heldConfigWriteLock,
401
445
  });
402
446
  let retainedLines = restored.retainedProviderTable ?? null;
403
447
  if (restored.success) {
@@ -416,11 +460,11 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
416
460
  // idempotent — it checks for the table before appending — so the three do not have to
417
461
  // be told apart here.
418
462
  if (settled.kind === "stand-down" && settled.retainProviderTable && capturedBlock !== null) {
419
- retainedLines = retainOcxProviderTableOnDisk(capturedBlock) ?? retainedLines;
463
+ retainedLines = retainOcxProviderTableOnDisk(capturedBlock, heldConfigWriteLock) ?? retainedLines;
420
464
  }
421
465
  // All native work and the final migration check succeeded. A field-level
422
466
  // fallback retains its snapshot, but no longer owns this Codex home.
423
- releaseJournalHomeBinding();
467
+ publishCodexArtifact(JOURNAL_PATH, heldConfigWriteLock, (_path, hooks) => releaseJournalHomeBinding(hooks));
424
468
  }
425
469
  if (restored.success && retainedLines !== null) {
426
470
  return {
@@ -504,6 +548,7 @@ function restoreCodexCatalogArtifact(
504
548
  export async function restoreNativeCodexAsync(
505
549
  options: { revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
506
550
  ): Promise<CodexNativeRestoreResult> {
551
+ if (codexHomeIsAbsent(getCodexHome())) return skippedRestoreEnvelope(true, "Codex config not found; no native restore was needed.");
507
552
  const sibling = siblingRestoreSkip();
508
553
  if (sibling) return sibling;
509
554
  try {
@@ -524,7 +569,7 @@ async function restoreNativeCodexAsyncImpl(
524
569
  // External-provider courtesy: only the stale journal is removed. The
525
570
  // history worker must not launch — it would turn a read-mostly courtesy
526
571
  // result into a history mutation on a home we do not own.
527
- removeJournal();
572
+ cleanExternalProviderJournal();
528
573
  return externalProviderRestoreResult(activeProvider);
529
574
  }
530
575
 
@@ -560,6 +605,22 @@ async function restoreNativeCodexAsyncImpl(
560
605
  let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
561
606
 
562
607
  if (eligibility.kind === "coordinated" || eligibility.kind === "adopt") {
608
+ /*
609
+ * File lock BEFORE N — the same order injection takes. With the config
610
+ * writes living inside the journal replay under the file lock, a restore
611
+ * that took N first while a sibling held the file lock was the one
612
+ * deliberate inversion; acquiring here first removes it.
613
+ */
614
+ const configLock = await acquireConfigWriteLock(CODEX_CONFIG_PATH);
615
+ if (!configLock.ok) {
616
+ config = {
617
+ state: "failed",
618
+ changed: false,
619
+ action: "failed",
620
+ retryable: configLock.error === "locked", message: configWriteLockFailureMessage(configLock),
621
+ };
622
+ } else try {
623
+ beginCodexWriteSection(configLock.handle);
563
624
  // The restore has no candidate bytes to witness; freshness comes from the
564
625
  // filesystem reads and the desired-state re-read performed under the lock.
565
626
  const witness = { authoritySnapshotId: "codex-native-restore" };
@@ -595,12 +656,12 @@ async function restoreNativeCodexAsyncImpl(
595
656
  const preImages = captureCodexPreImages();
596
657
  let restored: CodexRestoreConfigResult;
597
658
  try {
598
- restored = restoreCodexConfigInline(eligibility.kind, options);
659
+ restored = restoreCodexConfigInline(eligibility.kind, options, configLock.handle);
599
660
  // Throw inside N so the published remove transition rolls back too.
600
661
  if (restored.state === "failed") throw new CodexRestoreRefusal(restored);
601
662
  } catch (error) {
602
663
  if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
603
- const compensated = restoreCodexPreImages(preImages);
664
+ const compensated = restoreCodexPreImages(preImages, configLock.handle);
604
665
  if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
605
666
  throw error;
606
667
  }
@@ -632,6 +693,9 @@ async function restoreNativeCodexAsyncImpl(
632
693
  config = coordinated.value.config;
633
694
  transitionReceipt = coordinated.value.receipt;
634
695
  }
696
+ } finally {
697
+ releaseConfigWriteLock(configLock.handle);
698
+ }
635
699
  } else {
636
700
  // Legacy-uncoordinated (or unresolvable) homes keep the unserialized path
637
701
  // they have always had; restore is the escape hatch and must not strand
@@ -713,6 +777,7 @@ function homeOwnerRestoreRefusal(error: CodexHomeOwnerRefusal): CodexNativeResto
713
777
  export function restoreNativeCodex(
714
778
  options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
715
779
  ): CodexNativeRestoreResult {
780
+ if (codexHomeIsAbsent(getCodexHome())) return skippedRestoreEnvelope(true, "Codex config not found; no native restore was needed.");
716
781
  const sibling = siblingRestoreSkip();
717
782
  if (sibling) return sibling;
718
783
  try {
@@ -729,7 +794,7 @@ function restoreNativeCodexImpl(
729
794
  ): CodexNativeRestoreResult {
730
795
  const activeProvider = currentExternalCodexModelProvider();
731
796
  if (activeProvider) {
732
- removeJournal();
797
+ cleanExternalProviderJournal();
733
798
  return externalProviderRestoreResult(activeProvider);
734
799
  }
735
800
  if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
@@ -7,6 +7,8 @@
7
7
  import { createHash } from "node:crypto";
8
8
  import { existsSync, lstatSync, readFileSync } from "node:fs";
9
9
 
10
+ import { publishCodexArtifact } from "./inject/config-write-section";
11
+ import { withConfigWriteLockHeld, type LockHandle } from "./config-write-lock";
10
12
  import { atomicWriteFile } from "../config";
11
13
  import type { CodexWriteLockResult, CodexWriteLockSkipReason } from "./codex-write-lock";
12
14
  import { HUB_GATED_SKIP_MESSAGE } from "./desired-state";
@@ -367,7 +369,9 @@ export function recordCodexNativeTransactionProvenance(
367
369
  */
368
370
  export function restoreCodexPreImages(
369
371
  pre: CodexPreImages,
372
+ heldConfigWriteLock?: LockHandle,
370
373
  ): { complete: boolean; unrestored: readonly string[] } {
374
+ const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, heldConfigWriteLock, held => {
371
375
  const unrestored: string[] = [];
372
376
  const surfaces: readonly [string, string, string | null][] = [
373
377
  ["config", CODEX_CONFIG_PATH, pre.config],
@@ -375,18 +379,23 @@ export function restoreCodexPreImages(
375
379
  ["journal", JOURNAL_PATH, pre.journal],
376
380
  ];
377
381
  for (const [name, path, bytes] of surfaces) {
382
+ // The current journal remains recovery authority until both artifacts are restored.
383
+ if (name === "journal" && unrestored.length > 0) { unrestored.push(name); continue; }
378
384
  try {
379
- if (bytes === null) {
380
- // Absent before, so absent after. A leftover file is not a restoration.
381
- if (existsSync(path)) require("node:fs").unlinkSync(path);
382
- } else if (readOrNull(path) !== bytes) {
383
- atomicWriteFile(path, bytes);
384
- }
385
+ publishCodexArtifact(path, held, (destination, hooks) => {
386
+ if (bytes === null) {
387
+ hooks.validateBeforeRename?.(destination);
388
+ if (existsSync(destination)) require("node:fs").unlinkSync(destination);
389
+ hooks.afterRename?.(destination);
390
+ } else if (readOrNull(destination) !== bytes) atomicWriteFile(destination, bytes, undefined, hooks);
391
+ });
385
392
  } catch {
386
393
  unrestored.push(name);
387
394
  }
388
395
  }
389
396
  return { complete: unrestored.length === 0, unrestored };
397
+ });
398
+ return locked.ok ? locked.value : { complete: false, unrestored: ["config", "profile", "journal"] };
390
399
  }
391
400
 
392
401
  export function buildInjectWitness(
@@ -1,3 +1,4 @@
1
+ import { beginCodexWriteSection, cleanExternalProviderJournal, publishCodexArtifact } from "./inject/config-write-section";
1
2
  import { closeSync, existsSync, openSync, readFileSync, statSync } from "node:fs";
2
3
  import { dirname } from "node:path";
3
4
  import {
@@ -9,6 +10,7 @@ import {
9
10
  } from "../config";
10
11
  import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
11
12
  import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock";
13
+ import { ConfigWriteDestinationChanged, ConfigWriteLockRefusal, configWriteLockFailureMessage, acquireConfigWriteLock, CONFIG_WRITE_LOCK_WAIT_MS, releaseConfigWriteLock, type LockHandle } from "./config-write-lock";
12
14
  import {
13
15
  localClientSkipMessage,
14
16
  localClientSkipReason,
@@ -36,7 +38,7 @@ import {
36
38
  resolveEffectiveUserIdentity,
37
39
  } from "./user-identity";
38
40
  import {
39
- hasUnverifiedJournalBaseline,
41
+ JOURNAL_PATH, hasUnverifiedJournalBaseline,
40
42
  markJournalInjectedState,
41
43
  journaledInjectedOpenaiBaseUrl,
42
44
  journaledInjectedRealtimeWsBaseUrl,
@@ -196,6 +198,8 @@ export async function injectCodexConfig(
196
198
  }
197
199
  try { assertCodexHomeOwner(getCodexHome()); return await injectCodexConfigImpl(port, config, options); }
198
200
  catch (error) {
201
+ if (error instanceof ConfigWriteDestinationChanged) return { success: false, retryable: false, message: error.message };
202
+ if (error instanceof ConfigWriteLockRefusal) return { success: false, retryable: error.retryable, message: error.message };
199
203
  if (error instanceof CodexHomeOwnerRefusal) return { success: false, ownershipRefusal: error.reason, message: error.message };
200
204
  if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` };
201
205
  if (error instanceof CodexInjectRefusal) return error.result;
@@ -239,12 +243,7 @@ async function injectCodexConfigImpl(
239
243
  // A launcher may have journaled before the provider manager took ownership. Never let shutdown
240
244
  // replay that stale snapshot over externally managed config.
241
245
  if (!options.validateOnly) {
242
- if (options.beforeClientWrite) {
243
- withConfigMutationLockSync(() => {
244
- runClientWriteGuard(options.beforeClientWrite);
245
- removeJournal();
246
- });
247
- } else removeJournal();
246
+ cleanExternalProviderJournal(options.beforeClientWrite ? () => runClientWriteGuard(options.beforeClientWrite) : undefined);
248
247
  }
249
248
  const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(
250
249
  config,
@@ -419,11 +418,26 @@ async function injectCodexConfigImpl(
419
418
  */
420
419
  const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => {
421
420
  assertCodexHomeOwner(getCodexHome());
422
- if (missingConfig) createEmptyCodexConfigInBoundary();
421
+ /*
422
+ * Re-admission under the file lock: `rawContent` and `admittedPlan` were
423
+ * read before this section acquired it, so the file may have moved while
424
+ * the lock was being waited out — committing the stale plan would rename
425
+ * over bytes a competing writer landed. Refuse retryably; the next pass
426
+ * re-reads fresh bytes (the same witness check the coordinated path does).
427
+ */
428
+ if ((existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null)
429
+ !== (missingConfig ? null : rawContent)) {
430
+ throw new CodexInjectRefusal({
431
+ success: false,
432
+ retryable: true,
433
+ message: "Codex config injection refused: config.toml changed while the write lock was being acquired. Retry to inject on the latest bytes.",
434
+ });
435
+ }
436
+ if (missingConfig) publishCodexArtifact(CODEX_CONFIG_PATH, heldConfigWriteLock, (_path, hooks) => { hooks.validateBeforeRename?.(CODEX_CONFIG_PATH); createEmptyCodexConfigInBoundary(); hooks.afterRename?.(CODEX_CONFIG_PATH); });
423
437
  let nativeInput = rawContent;
424
438
  let plan = admittedPlan;
425
439
  if (v1Reconcile) {
426
- const reconciled = v1Reconcile.run();
440
+ const reconciled = v1Reconcile.run(heldConfigWriteLock);
427
441
  if (!reconciled.ok) {
428
442
  throw new CodexInjectRefusal({ success: false, message: reconciled.message });
429
443
  }
@@ -458,19 +472,19 @@ async function injectCodexConfigImpl(
458
472
  plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan);
459
473
  historyArtifactStageForTests?.("after-preflight");
460
474
  assertCodexHomeOwner(getCodexHome());
461
- writeJournal({
475
+ publishCodexArtifact(JOURNAL_PATH, heldConfigWriteLock, (_path, hooks) => writeJournal({
462
476
  currentStateIsNative: journalBaselineIsNative(nativeInput),
463
477
  configContent: plan.baselineContent,
464
478
  owner: options.journalOwner,
465
- });
479
+ }, hooks));
466
480
  // A native snapshot may have been refreshed above. An older hashless routed snapshot
467
481
  // must not gain the new injection's hash and later overwrite preserved user edits.
468
482
  if (hasUnverifiedJournalBaseline(plan.baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage);
469
- atomicWriteFile(CODEX_CONFIG_PATH, plan.content);
483
+ publishCodexArtifact(CODEX_CONFIG_PATH, heldConfigWriteLock, (path, hooks) => atomicWriteFile(path, plan.content, undefined, hooks));
470
484
  historyArtifactStageForTests?.("after-config");
471
485
  assertCodexHomeOwner(getCodexHome());
472
- atomicWriteFile(CODEX_PROFILE_PATH, plan.profileContent);
473
- markJournalInjectedState(plan.content, plan.profileContent, {
486
+ publishCodexArtifact(CODEX_PROFILE_PATH, heldConfigWriteLock, (path, hooks) => atomicWriteFile(path, plan.profileContent, undefined, hooks));
487
+ publishCodexArtifact(JOURNAL_PATH, heldConfigWriteLock, (_path, hooks) => markJournalInjectedState(plan.content, plan.profileContent, {
474
488
  // A root override is ours whenever we wrote one and no user-owned value won. That is
475
489
  // loopback Design B, the client-compaction form, and a table form that retained the
476
490
  // root line for paginated history. Journaling it matters because the marker comment
@@ -494,7 +508,7 @@ async function injectCodexConfigImpl(
494
508
  // This is the catalog artifact selected for this injection, even when config.toml
495
509
  // already points at that path and therefore needs no textual rewrite.
496
510
  injectedCatalogPath: plan.catalogPath,
497
- });
511
+ }, hooks));
498
512
  historyArtifactStageForTests?.("after-artifacts");
499
513
  // Detect migration throughout the artifact transaction, not just at entry.
500
514
  plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan);
@@ -508,6 +522,16 @@ async function injectCodexConfigImpl(
508
522
  */
509
523
  let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
510
524
 
525
+ /*
526
+ * The file-level write lock every config.toml writer shares (see
527
+ * config-write-lock.ts). The injector takes it FIRST — before the SQLite N
528
+ * lock below — so the feature transition and the artifact commit can never
529
+ * be interleaved with a scalar edit, a remove/restore transform, or a
530
+ * prompt-layers commit. The reconcile consumes the same handle rather than
531
+ * re-acquiring the file it is already holding.
532
+ */
533
+ let heldConfigWriteLock: LockHandle | undefined;
534
+
511
535
  /*
512
536
  * The plan the committed write actually used: the admitted plan, or the
513
537
  * re-derivation from the post-reconcile bytes when the feature transition
@@ -516,6 +540,19 @@ async function injectCodexConfigImpl(
516
540
  */
517
541
  let effectivePlan: CodexInjectionPlanOk = admittedPlan;
518
542
 
543
+ const configLock = await acquireConfigWriteLock(CODEX_CONFIG_PATH, {
544
+ timeoutMs: Math.min(options.lockTimeoutMs ?? CONFIG_WRITE_LOCK_WAIT_MS, CONFIG_WRITE_LOCK_WAIT_MS),
545
+ });
546
+ if (!configLock.ok) {
547
+ return {
548
+ success: false,
549
+ retryable: configLock.error === "locked",
550
+ message: configWriteLockFailureMessage(configLock),
551
+ };
552
+ }
553
+ heldConfigWriteLock = configLock.handle;
554
+ try {
555
+ beginCodexWriteSection(configLock.handle);
519
556
  if (eligibility.kind === "legacy-uncoordinated") {
520
557
  const applyLegacy = (): CodexInjectResult | undefined => {
521
558
  const legacyGateSnapshot = loadConfig();
@@ -544,8 +581,8 @@ async function injectCodexConfigImpl(
544
581
  applyNativeArtifacts(resolved.plan, resolved.nativeInput);
545
582
  effectivePlan = resolved.plan;
546
583
  } catch (error) {
547
- if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
548
- const restored = restoreCodexPreImages(preImages);
584
+ if (error instanceof ConfigWriteDestinationChanged || codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
585
+ const restored = restoreCodexPreImages(preImages, heldConfigWriteLock);
549
586
  if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored);
550
587
  throw error;
551
588
  }
@@ -565,7 +602,7 @@ async function injectCodexConfigImpl(
565
602
  ...(eligibility.kind === "adopt" ? { adoption: { direction: "apply" as const } } : {}),
566
603
  onPostCallbackFailure: () => {
567
604
  if (!coordinatedPreImages) throw new Error("Codex injection preimages were not captured.");
568
- const restored = restoreCodexPreImages(coordinatedPreImages);
605
+ const restored = restoreCodexPreImages(coordinatedPreImages, heldConfigWriteLock);
569
606
  if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored);
570
607
  },
571
608
  admitted: { authoritySnapshotId: witness.comparisonId },
@@ -639,10 +676,10 @@ async function injectCodexConfigImpl(
639
676
  }
640
677
  applyNativeArtifacts(resolved.plan, resolved.nativeInput);
641
678
  } catch (error) {
642
- if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
679
+ if (error instanceof ConfigWriteDestinationChanged || codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
643
680
  // Compensate, then ALWAYS throw. Returning a partial result would let the
644
681
  // lock commit a row describing an apply that did not finish.
645
- const restored = restoreCodexPreImages(preImages);
682
+ const restored = restoreCodexPreImages(preImages, heldConfigWriteLock);
646
683
  if (!restored.complete) {
647
684
  throw new CodexPartialWriteError(restored.unrestored);
648
685
  }
@@ -676,6 +713,10 @@ async function injectCodexConfigImpl(
676
713
  );
677
714
  transitionReceipt = coordinated.value.receipt;
678
715
  }
716
+ } finally {
717
+ releaseConfigWriteLock(configLock.handle);
718
+ heldConfigWriteLock = undefined;
719
+ }
679
720
  // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
680
721
  // the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore
681
722
  // machinery; cheap no-op when there is nothing to migrate). The client-compaction opt-in keeps
@@ -926,6 +967,7 @@ function missingCodexConfigAdmission(): MissingCodexConfig {
926
967
  * exclusive: a file that appeared since admission belongs to another writer, and this plan,
927
968
  * derived from an absent file, must not replace it.
928
969
  */
970
+
929
971
  function createEmptyCodexConfigInBoundary(): void {
930
972
  try {
931
973
  closeSync(openSync(CODEX_CONFIG_PATH, "wx", 0o600));