@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -1,8 +1,45 @@
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 { withCodexWriteLock } from "./codex-write-lock";
11
+ import { resolveCodexHistoryTransition } from "./history-transition";
12
+ import {
13
+ buildInjectWitness,
14
+ captureCodexPreImages,
15
+ codexInjectLockOutcome,
16
+ codexWriteCoordinationEligibility,
17
+ CodexPartialWriteError,
18
+ CodexWriteConflictError,
19
+ DEFAULT_INJECT_LOCK_TIMEOUT_MS,
20
+ recomputeInjectWitness,
21
+ restoreCodexPreImages,
22
+ } from "./inject-coordination";
23
+ import { readIntegrationRecord } from "./integration-record";
24
+ import { classifyNativeRoutedResidue } from "./native-residue";
25
+ import {
26
+ resolveCodexCoordinatorDatabasePath,
27
+ resolveEffectiveUserIdentity,
28
+ } from "./user-identity";
29
+ import {
30
+ markJournalInjectedState,
31
+ removeJournal,
32
+ restoreJournalState,
33
+ writeJournal,
34
+ } from "./journal";
35
+ import { withCatalogWriteSerialization } from "./catalog-write-serialization";
36
+ import { restoreCodexCatalogWithPermit } from "./catalog/sync";
37
+ import { syncCodexHistoryProvider } from "./history-provider";
38
+ import {
39
+ deriveCodexHistoryOperation,
40
+ resolveCodexHistoryJobTarget,
41
+ runCodexHistoryJob,
42
+ } from "./history-job";
6
43
  import {
7
44
  OCX_SECTION_MARKER,
8
45
  hasInjectedCodexRouting,
@@ -13,7 +50,16 @@ import {
13
50
  rootTomlString,
14
51
  tomlStringPattern,
15
52
  } from "./injected-marker";
16
- import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths";
53
+ import {
54
+ CODEX_CONFIG_PATH,
55
+ CODEX_PROFILE_PATH,
56
+ DEFAULT_CATALOG_PATH,
57
+ getCodexHome,
58
+ parseTomlString,
59
+ readRootTomlString,
60
+ resolveCodexConfigPath,
61
+ tomlString,
62
+ } from "./paths";
17
63
  import { resolveEffectiveProjectModelProvider } from "./project-config-warnings";
18
64
  import {
19
65
  transformManagedSubagentDefaults,
@@ -27,7 +73,9 @@ export { hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl };
27
73
 
28
74
  export function externalCodexModelProvider(content: string): string | null {
29
75
  const provider = resolveEffectiveProjectModelProvider(content).provider;
30
- return provider && provider !== "openai" && provider !== "opencodex" ? provider : null;
76
+ return provider && provider !== "openai" && provider !== "opencodex"
77
+ ? provider
78
+ : null;
31
79
  }
32
80
 
33
81
  export function currentExternalCodexModelProvider(): string | null {
@@ -70,15 +118,29 @@ export interface InjectCodexOptions {
70
118
  * failing on a missing model_catalog_json file.
71
119
  */
72
120
  catalogPath?: string | null;
121
+ /**
122
+ * How long to wait for the Codex write lock before reporting contention.
123
+ *
124
+ * Bounded by default so a stuck holder cannot wedge `ocx start`; an explicit
125
+ * caller that is willing to wait can raise it.
126
+ */
127
+ lockTimeoutMs?: number;
73
128
  }
74
129
 
75
130
  function configuredManagedSubagentDefaults(
76
- config: Pick<OcxConfig, "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"> | undefined,
131
+ config:
132
+ | Pick<
133
+ OcxConfig,
134
+ "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"
135
+ >
136
+ | undefined,
77
137
  ): ManagedSubagentDefaults | null {
78
138
  if (!subagentDefaultSyncEffective(config ?? {})) return null;
79
139
  return {
80
140
  model: config!.injectionModel!.trim(),
81
- ...(config!.injectionEffort?.trim() ? { reasoningEffort: config!.injectionEffort.trim() } : {}),
141
+ ...(config!.injectionEffort?.trim()
142
+ ? { reasoningEffort: config!.injectionEffort.trim() }
143
+ : {}),
82
144
  };
83
145
  }
84
146
 
@@ -98,7 +160,13 @@ function configuredManagedSubagentDefaults(
98
160
  */
99
161
  export function isLoopbackHostname(hostname: string | undefined): boolean {
100
162
  const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
101
- return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
163
+ return (
164
+ normalized === "" ||
165
+ normalized === "localhost" ||
166
+ normalized === "127.0.0.1" ||
167
+ normalized === "::1" ||
168
+ normalized === "[::1]"
169
+ );
102
170
  }
103
171
 
104
172
  export function providerBaseHost(hostname: string | undefined): string {
@@ -107,16 +175,29 @@ export function providerBaseHost(hostname: string | undefined): string {
107
175
  // Match what the server actually binds. Writing "localhost" while binding IPv4-only
108
176
  // 127.0.0.1 breaks on Windows, where localhost commonly resolves to ::1 first.
109
177
  if (lower === "::1" || lower === "[::1]") return "[::1]";
110
- if (isLoopbackHostname(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1";
178
+ if (
179
+ isLoopbackHostname(trimmed) ||
180
+ trimmed === "0.0.0.0" ||
181
+ trimmed === "::" ||
182
+ trimmed === "[::]"
183
+ )
184
+ return "127.0.0.1";
111
185
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
112
186
  return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
113
187
  }
114
188
 
115
- export function shouldInjectApiAuthHeader(config: Pick<OcxConfig, "hostname"> | undefined): boolean {
189
+ export function shouldInjectApiAuthHeader(
190
+ config: Pick<OcxConfig, "hostname"> | undefined,
191
+ ): boolean {
116
192
  return !isLoopbackHostname(config?.hostname);
117
193
  }
118
194
 
119
- export function buildProviderTableBlock(port: number, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string {
195
+ export function buildProviderTableBlock(
196
+ port: number,
197
+ supportsWebsockets = false,
198
+ includeApiAuthHeader = false,
199
+ hostname?: string,
200
+ ): string {
120
201
  const host = providerBaseHost(hostname);
121
202
  const lines = [
122
203
  "",
@@ -128,13 +209,18 @@ export function buildProviderTableBlock(port: number, supportsWebsockets = false
128
209
  "requires_openai_auth = true",
129
210
  ];
130
211
  if (includeApiAuthHeader) {
131
- lines.push('env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }');
212
+ lines.push(
213
+ 'env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }',
214
+ );
132
215
  }
133
216
  if (supportsWebsockets) lines.push("supports_websockets = true");
134
217
  return lines.join("\n") + "\n";
135
218
  }
136
219
 
137
- export function buildOpenaiBaseUrlLine(port: number, hostname?: string): string {
220
+ export function buildOpenaiBaseUrlLine(
221
+ port: number,
222
+ hostname?: string,
223
+ ): string {
138
224
  return `openai_base_url = "http://${providerBaseHost(hostname)}:${port}/v1"`;
139
225
  }
140
226
 
@@ -144,9 +230,13 @@ export function buildOpenaiBaseUrlLine(port: number, hostname?: string): string
144
230
  * in place. A user's OWN root `openai_base_url` (no marker above it) is respected — we keep it
145
231
  * and inject nothing, reporting `keptUserBaseUrl` so the caller can surface it.
146
232
  */
147
- export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: string): { content: string; keptUserBaseUrl: boolean } {
233
+ export function setRootOpenaiBaseUrl(
234
+ content: string,
235
+ port: number,
236
+ hostname?: string,
237
+ ): { content: string; keptUserBaseUrl: boolean } {
148
238
  const lines = content.split("\n");
149
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
239
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
150
240
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
151
241
  const key = buildOpenaiBaseUrlLine(port, hostname);
152
242
 
@@ -159,7 +249,16 @@ export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: s
159
249
  }
160
250
 
161
251
  if (firstTable === -1) {
162
- return { content: content.replace(/\n+$/, "") + "\n" + OCX_SECTION_MARKER + "\n" + key + "\n", keptUserBaseUrl: false };
252
+ return {
253
+ content:
254
+ content.replace(/\n+$/, "") +
255
+ "\n" +
256
+ OCX_SECTION_MARKER +
257
+ "\n" +
258
+ key +
259
+ "\n",
260
+ keptUserBaseUrl: false,
261
+ };
163
262
  }
164
263
  let insertAt = firstTable;
165
264
  while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
@@ -174,7 +273,7 @@ export function setRootOpenaiBaseUrl(content: string, port: number, hostname?: s
174
273
  */
175
274
  export function stripInjectedOpenaiBaseUrl(content: string): string {
176
275
  const lines = content.split("\n");
177
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
276
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
178
277
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
179
278
  const drop = new Set<number>();
180
279
  for (let i = 0; i < rootEnd; i++) {
@@ -190,7 +289,8 @@ export function stripInjectedOpenaiBaseUrl(content: string): string {
190
289
  return lines.filter((_, i) => !drop.has(i)).join("\n");
191
290
  }
192
291
 
193
- export type CodexRoutingKind = "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
292
+ export type CodexRoutingKind =
293
+ "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
194
294
 
195
295
  type RoutingEndpointKind = "local" | "remote" | "unknown";
196
296
 
@@ -198,7 +298,7 @@ function ipv4Octets(hostname: string): number[] | null {
198
298
  const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
199
299
  if (dotted) {
200
300
  const octets = dotted.slice(1).map(Number);
201
- return octets.some(octet => octet > 255) ? null : octets;
301
+ return octets.some((octet) => octet > 255) ? null : octets;
202
302
  }
203
303
  const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname);
204
304
  if (!mapped) return null;
@@ -211,13 +311,18 @@ function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
211
311
  try {
212
312
  const url = new URL(value);
213
313
  if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown";
214
- const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
314
+ const hostname = url.hostname
315
+ .toLowerCase()
316
+ .replace(/^\[|\]$/g, "")
317
+ .replace(/\.$/, "");
215
318
  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";
319
+ if (hostname === "localhost" || hostname.endsWith(".localhost"))
320
+ return "local";
321
+ if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0")
322
+ return "local";
218
323
  const octets = ipv4Octets(hostname);
219
324
  if (octets) {
220
- if (octets.every(octet => octet === 0)) return "local";
325
+ if (octets.every((octet) => octet === 0)) return "local";
221
326
  if (octets[0] === 127) return "local";
222
327
  return "remote";
223
328
  }
@@ -239,15 +344,25 @@ export function classifyCodexRouting(content: string): CodexRoutingKind {
239
344
  }
240
345
  const rootProvider = rootTomlString(content, "model_provider");
241
346
  if (rootProvider) {
242
- const providerTableExists = providerTableStart(content.split("\n"), rootProvider) !== -1;
243
- const providerBaseUrl = providerTableString(content, rootProvider, "base_url");
347
+ const providerTableExists =
348
+ providerTableStart(content.split("\n"), rootProvider) !== -1;
349
+ const providerBaseUrl = providerTableString(
350
+ content,
351
+ rootProvider,
352
+ "base_url",
353
+ );
244
354
  if (providerBaseUrl) {
245
355
  const endpoint = classifyRoutingEndpoint(providerBaseUrl);
246
356
  if (endpoint === "unknown") return "unknown";
247
357
  if (rootProvider === "opencodex") return "opencodex-local";
248
358
  return endpoint === "local" ? "custom-local" : "custom-remote";
249
359
  }
250
- if (rootProvider === "opencodex" || providerTableExists || rootProvider !== "openai") return "unknown";
360
+ if (
361
+ rootProvider === "opencodex" ||
362
+ providerTableExists ||
363
+ rootProvider !== "openai"
364
+ )
365
+ return "unknown";
251
366
  }
252
367
  return "native";
253
368
  }
@@ -282,7 +397,7 @@ export function getCodexRoutingKind(): CodexRoutingKind {
282
397
  */
283
398
  function stripExistingModelProvider(content: string): string {
284
399
  const lines = content.split("\n");
285
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
400
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
286
401
  const out: string[] = [];
287
402
  lines.forEach((line, i) => {
288
403
  if (/^\s*model_provider\s*=/.test(line)) {
@@ -303,7 +418,7 @@ function stripExistingModelProvider(content: string): string {
303
418
  */
304
419
  export function stripRootContextWindowOverrides(content: string): string {
305
420
  const lines = content.split("\n");
306
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
421
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
307
422
  return lines
308
423
  .filter((line, i) => {
309
424
  const isRoot = firstTable === -1 || i < firstTable;
@@ -314,7 +429,7 @@ export function stripRootContextWindowOverrides(content: string): string {
314
429
 
315
430
  function stripRootRoutedModel(content: string): string {
316
431
  const lines = content.split("\n");
317
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
432
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
318
433
  return lines
319
434
  .filter((line, i) => {
320
435
  const isRoot = firstTable === -1 || i < firstTable;
@@ -333,7 +448,7 @@ function stripRootRoutedModel(content: string): string {
333
448
  */
334
449
  function setRootModelProvider(content: string): string {
335
450
  const lines = content.split("\n");
336
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
451
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
337
452
  const key = 'model_provider = "opencodex"';
338
453
  if (firstTable === -1) {
339
454
  return content.replace(/\n+$/, "") + "\n" + key + "\n";
@@ -350,11 +465,13 @@ function readRootModelCatalogPath(content: string): string | null {
350
465
 
351
466
  function setRootModelCatalogPath(content: string, catalogPath: string): string {
352
467
  const lines = content.split("\n");
353
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
468
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
354
469
  const key = `model_catalog_json = ${tomlString(catalogPath)}`;
355
470
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
356
471
  for (let i = 0; i < rootEnd; i++) {
357
- const m = lines[i].match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
472
+ const m = lines[i].match(
473
+ /^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/,
474
+ );
358
475
  if (!m) continue;
359
476
  const existing = parseTomlString(m[1]);
360
477
  if (isOpencodexCatalogPath(existing)) {
@@ -390,32 +507,52 @@ function removeProfileSection(content: string): string {
390
507
  }
391
508
  filtered.push(line);
392
509
  }
393
- return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
510
+ return (
511
+ filtered
512
+ .join("\n")
513
+ .replace(/\n{3,}/g, "\n\n")
514
+ .trimEnd() + "\n"
515
+ );
394
516
  }
395
517
 
396
518
  function normalizeServiceTier(content: string): string {
397
- return content.replace(/^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, '$1"fast"');
519
+ return content.replace(
520
+ /^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm,
521
+ '$1"fast"',
522
+ );
398
523
  }
399
524
 
400
- function ensureFastModeFeature(content: string): string {
525
+ function ensureFastModeFeature(content: string, fastMode?: boolean): string {
526
+ // Tri-state fast mode (see OcxConfig.fastMode): true forces `fast_mode = true`,
527
+ // false forces `fast_mode = false`, and undefined leaves the user's config
528
+ // untouched (no [features] table is added and an existing fast_mode line is
529
+ // preserved as-is). Table and key matching accept the valid TOML spellings
530
+ // `[features] # comment`, `["features"]` / `['features']`, and quoted keys.
401
531
  const lines = content.split("\n");
402
- const featuresStart = lines.findIndex(line => line.trim() === "[features]");
532
+ const featuresHeader = /^\s*\[(["']?)\s*features\s*\1\]\s*(?:#.*)?$/;
533
+ const fastModeKey = /^\s*(?:"fast_mode"|'fast_mode'|fast_mode)\s*=/;
534
+ const featuresStart = lines.findIndex(line => featuresHeader.test(line));
403
535
  if (featuresStart === -1) {
404
- return content.trimEnd() + "\n\n[features]\nfast_mode = true\n";
536
+ if (fastMode === undefined) return content;
537
+ return content.trimEnd() + "\n\n[features]\nfast_mode = " + (fastMode ? "true" : "false") + "\n";
405
538
  }
406
539
 
407
- const nextTable = lines.findIndex((line, index) => index > featuresStart && /^\s*\[/.test(line));
540
+ const nextTable = lines.findIndex(
541
+ (line, index) => index > featuresStart && /^\s*\[/.test(line),
542
+ );
408
543
  const featuresEnd = nextTable === -1 ? lines.length : nextTable;
409
544
  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");
545
+ if (fastModeKey.test(lines[i])) {
546
+ if (fastMode === undefined) return lines.join("\n");
547
+ lines[i] = lines[i].replace(/^(\s*)(?:"fast_mode"|'fast_mode'|fast_mode)\s*=.*$/, `$1fast_mode = ${fastMode ? "true" : "false"}`);
412
548
  return lines.join("\n");
413
549
  }
414
550
  }
415
551
 
552
+ if (fastMode === undefined) return lines.join("\n");
416
553
  let insertAt = featuresEnd;
417
554
  while (insertAt > featuresStart + 1 && lines[insertAt - 1].trim() === "") insertAt--;
418
- lines.splice(insertAt, 0, "fast_mode = true");
555
+ lines.splice(insertAt, 0, `fast_mode = ${fastMode ? "true" : "false"}`);
419
556
  return lines.join("\n");
420
557
  }
421
558
 
@@ -426,14 +563,16 @@ function isOpencodexCatalogPath(path: string): boolean {
426
563
  function stripOpencodexCatalogPath(content: string): string {
427
564
  return content
428
565
  .split("\n")
429
- .filter(line => {
430
- const m = line.match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
566
+ .filter((line) => {
567
+ const m = line.match(
568
+ /^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/,
569
+ );
431
570
  return !m || !isOpencodexCatalogPath(parseTomlString(m[1]));
432
571
  })
433
572
  .join("\n");
434
573
  }
435
574
 
436
- export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string {
575
+ export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string, fastMode?: boolean): string {
437
576
  const host = providerBaseHost(hostname);
438
577
  // Design B (loopback): the reference/fallback file documents the root override form.
439
578
  // Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry
@@ -446,7 +585,7 @@ export function buildProfileFile(port: number, catalogPath?: string | null, supp
446
585
  buildOpenaiBaseUrlLine(port, hostname),
447
586
  ];
448
587
  if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
449
- lines.push("", "[features]", "fast_mode = true", "");
588
+ if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`, "");
450
589
  return lines.join("\n");
451
590
  }
452
591
  const lines = [
@@ -455,18 +594,22 @@ export function buildProfileFile(port: number, catalogPath?: string | null, supp
455
594
  'model_provider = "opencodex"',
456
595
  ];
457
596
  if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
458
- lines.push("", "[features]", "fast_mode = true");
597
+ if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`);
459
598
  lines.push(buildProviderTableBlock(port, supportsWebsockets, includeApiAuthHeader, hostname).trimEnd(), "");
460
599
  return lines.join("\n");
461
600
  }
462
601
 
463
- export function chooseCatalogPathForInjection(content: string, requested?: string | null): string | null {
602
+ export function chooseCatalogPathForInjection(
603
+ content: string,
604
+ requested?: string | null,
605
+ ): string | null {
464
606
  if (requested !== undefined) return requested;
465
607
 
466
608
  const existing = readRootModelCatalogPath(content);
467
609
  if (existing) {
468
610
  const resolved = resolveCodexConfigPath(existing);
469
- if (!isOpencodexCatalogPath(resolved) || existsSync(resolved)) return existing;
611
+ if (!isOpencodexCatalogPath(resolved) || existsSync(resolved))
612
+ return existing;
470
613
  }
471
614
 
472
615
  return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null;
@@ -478,9 +621,16 @@ export interface CodexInjectResult {
478
621
  nativeSubagentDefaultsWarning?: string;
479
622
  }
480
623
 
481
- export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise<CodexInjectResult> {
624
+ export async function injectCodexConfig(
625
+ port: number,
626
+ config?: OcxConfig,
627
+ options: InjectCodexOptions = {},
628
+ ): Promise<CodexInjectResult> {
482
629
  if (!existsSync(CODEX_CONFIG_PATH)) {
483
- return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
630
+ return {
631
+ success: false,
632
+ message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?`,
633
+ };
484
634
  }
485
635
 
486
636
  const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
@@ -489,13 +639,18 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
489
639
  // A launcher may have journaled before the provider manager took ownership. Never let shutdown
490
640
  // replay that stale snapshot over externally managed config.
491
641
  removeJournal();
492
- const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(config)
642
+ const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(
643
+ config,
644
+ )
493
645
  ? `Native Codex sub-agent defaults were not injected: external model_provider ${tomlString(activeProvider)} owns config.toml.`
494
646
  : undefined;
495
647
  return {
496
648
  success: true,
497
- ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
498
- message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` +
649
+ ...(nativeSubagentDefaultsWarning
650
+ ? { nativeSubagentDefaultsWarning }
651
+ : {}),
652
+ message:
653
+ `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` +
499
654
  ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` +
500
655
  ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` +
501
656
  `${shouldInjectApiAuthHeader(config) ? ` with x-opencodex-api-key from OPENCODEX_API_AUTH_TOKEN` : ""}.\n` +
@@ -508,23 +663,37 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
508
663
  // root routing key: inserting that key ahead of a marker-owned first table
509
664
  // would otherwise separate the table marker from its header. Ambiguous
510
665
  // markers fail closed without writing config, profile, or journal state.
511
- const nativeDefaultsBaseline = transformManagedSubagentDefaults(rawContent, null);
666
+ const nativeDefaultsBaseline = transformManagedSubagentDefaults(
667
+ rawContent,
668
+ null,
669
+ );
512
670
  if (!nativeDefaultsBaseline.ok) {
513
671
  return {
514
672
  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}.`,
673
+ message:
674
+ `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` +
675
+ `No files were changed; inspect ${CODEX_CONFIG_PATH}.`,
517
676
  };
518
677
  }
519
678
  const baselineContent = nativeDefaultsBaseline.content;
520
679
 
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
- });
680
+ /*
681
+ * The journal write used to happen HERE, before the transforms. It now happens
682
+ * inside the write lock further down, and the transforms were hoisted above it
683
+ * rather than the lock being narrowed to the three file writes.
684
+ *
685
+ * Why: the lock's witness hashes the CANDIDATE BYTES, and those are not final
686
+ * until `profileContent` and the EOL-applied `content` exist. Opening the lock
687
+ * before them would leave nothing to hash; keeping the journal outside the
688
+ * lock would leave the first artifact-creating write unserialized, which is
689
+ * the hole this edge exists to close.
690
+ *
691
+ * The move is safe because the region between here and the writes performs no
692
+ * filesystem mutation — its only touch is `existsSync` on the catalog paths
693
+ * (`chooseCatalogPathForInjection`) — and because `writeJournal` is called
694
+ * with `configContent`, so it snapshots the baseline it is handed rather than
695
+ * rereading `config.toml` underneath the transforms.
696
+ */
528
697
  // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write.
529
698
  const eol = dominantEol(rawContent);
530
699
  let content = applyEol(baselineContent, "\n");
@@ -542,10 +711,15 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
542
711
  content = stripExistingModelProvider(content);
543
712
  content = stripRootContextWindowOverrides(content);
544
713
  content = normalizeServiceTier(content);
545
- content = ensureFastModeFeature(content);
714
+ content = ensureFastModeFeature(content, config?.fastMode);
546
715
 
547
- const catalogPath = chooseCatalogPathForInjection(content, options.catalogPath);
548
- content = catalogPath ? setRootModelCatalogPath(content, catalogPath) : stripOpencodexCatalogPath(content);
716
+ const catalogPath = chooseCatalogPathForInjection(
717
+ content,
718
+ options.catalogPath,
719
+ );
720
+ content = catalogPath
721
+ ? setRootModelCatalogPath(content, catalogPath)
722
+ : stripOpencodexCatalogPath(content);
549
723
 
550
724
  const legacyMode = shouldInjectApiAuthHeader(config);
551
725
  let keptUserBaseUrl = false;
@@ -555,7 +729,15 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
555
729
  // 1) Root key BEFORE the first table header (must be a global, not nested under a table).
556
730
  content = setRootModelProvider(content);
557
731
  // 2) Provider table appended at EOF (position-independent).
558
- content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}), true, config?.hostname);
732
+ content =
733
+ content.trimEnd() +
734
+ "\n" +
735
+ buildProviderTableBlock(
736
+ port,
737
+ websocketsEnabled(config ?? {}),
738
+ true,
739
+ config?.hostname,
740
+ );
559
741
  } else {
560
742
  // Design B (loopback): a single root override; codex keeps its native `openai` provider id
561
743
  // so thread history is never remapped. Any legacy form was already stripped above.
@@ -566,66 +748,280 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
566
748
  }
567
749
 
568
750
  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;
751
+ const routingOwnershipWarning =
752
+ keptUserBaseUrl && desiredSubagentDefaults
753
+ ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing."
754
+ : undefined;
572
755
  const managedDefaults = transformManagedSubagentDefaults(
573
756
  content,
574
757
  keptUserBaseUrl ? null : desiredSubagentDefaults,
575
758
  );
576
759
  let nativeSubagentDefaultsWarning = routingOwnershipWarning;
577
- let managedDefaultsMessage = routingOwnershipWarning ? ` ⚠️ ${routingOwnershipWarning}\n` : "";
760
+ let managedDefaultsMessage = routingOwnershipWarning
761
+ ? ` ⚠️ ${routingOwnershipWarning}\n`
762
+ : "";
578
763
  if (managedDefaults.ok) {
579
764
  content = managedDefaults.content;
580
765
  if (desiredSubagentDefaults && managedDefaults.conflicts.length > 0) {
581
- const keys = managedDefaults.conflicts.map(conflict => `agents.${conflict.key}`).join(", ");
766
+ const keys = managedDefaults.conflicts
767
+ .map((conflict) => `agents.${conflict.key}`)
768
+ .join(", ");
582
769
  nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults were not injected: user-owned ${keys} preserved.`;
583
770
  managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`;
584
771
  }
585
772
  } else {
586
- const action = desiredSubagentDefaults && !keptUserBaseUrl
587
- ? "were not injected"
588
- : "could not be safely removed";
773
+ const action =
774
+ desiredSubagentDefaults && !keptUserBaseUrl
775
+ ? "were not injected"
776
+ : "could not be safely removed";
589
777
  nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults ${action}: ${managedDefaults.error}.`;
590
778
  managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`;
591
779
  }
592
780
 
593
- const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname);
781
+ const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname, config?.fastMode);
594
782
  content = applyEol(content, eol);
595
- atomicWriteFile(CODEX_CONFIG_PATH, content);
596
- atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
597
- markJournalInjectedState(content, profileContent);
783
+
784
+ /*
785
+ * The witness, built from the FINAL bytes. Everything it hashes is either the
786
+ * output about to be written or evidence that can be re-read under the lock;
787
+ * ownership rides along as recorded context because it is not re-observed
788
+ * there — see `write-coordination.ts`.
789
+ */
790
+ const persisted = readConfigAdmissionSnapshot();
791
+ const persistedIdentity =
792
+ persisted.kind === "read" ? persisted.contentSha256 : "unreadable";
793
+ const observedGeneration = observeConfigGeneration();
794
+ const generation =
795
+ observedGeneration.kind === "ready"
796
+ ? { present: true, value: observedGeneration.generation.value }
797
+ : { present: false, value: 0 };
798
+ const candidate = {
799
+ configBytes: content,
800
+ profileBytes: profileContent,
801
+ catalogPath,
802
+ };
803
+ const witness = buildInjectWitness(
804
+ candidate,
805
+ rawContent,
806
+ persistedIdentity,
807
+ generation,
808
+ "unknown",
809
+ );
810
+
811
+ /*
812
+ * THE COORDINATED SECTION.
813
+ *
814
+ * This is the write lock's first production caller. Everything above is
815
+ * classification and pure transformation; everything from here to the end of
816
+ * the callback replaces files, and two processes doing it at once is the
817
+ * interruption hazard this substrate exists to close.
818
+ *
819
+ * The witness hashes the bytes about to be written rather than the inputs that
820
+ * produced them, so two operations intending different output cannot share an
821
+ * id no matter which input differed.
822
+ */
823
+ /*
824
+ * Eligibility BEFORE acquisition, never "try and fall back".
825
+ *
826
+ * A home routed before this substrate existed cannot have its first
827
+ * coordinator row created — the guard that refuses is correct — and that
828
+ * describes every pre-substrate install. Attempting the lock there would enter
829
+ * a refusal path on the entire installed base, so the decision happens first
830
+ * and those homes keep the write sequence they have always used.
831
+ */
832
+ const eligibility = codexWriteCoordinationEligibility({
833
+ coordinatorPath: () =>
834
+ resolveCodexCoordinatorDatabasePath(
835
+ resolveEffectiveUserIdentity(),
836
+ getCodexHome(),
837
+ ),
838
+ residue: () => classifyNativeRoutedResidue(),
839
+ integrationRecord: () => readIntegrationRecord(),
840
+ });
841
+ if (eligibility.kind === "refused") {
842
+ return {
843
+ success: false,
844
+ message: `Codex configuration was not written: ${eligibility.reason}.`,
845
+ };
846
+ }
847
+
848
+ const applyNativeArtifacts = (): void => {
849
+ writeJournal({
850
+ currentStateIsNative: !hasInjectedCodexRouting(rawContent),
851
+ configContent: baselineContent,
852
+ });
853
+ atomicWriteFile(CODEX_CONFIG_PATH, content);
854
+ atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
855
+ markJournalInjectedState(content, profileContent);
856
+ };
857
+
858
+ /*
859
+ * Set only on the coordinated path: the generation/txId the transition just
860
+ * committed. The terminal history update CASes against this, so a job that
861
+ * was overtaken cannot overwrite the winner. Stays undefined for a
862
+ * legacy-uncoordinated home, which publishes no transition to resolve.
863
+ */
864
+ let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
865
+
866
+ if (eligibility.kind === "legacy-uncoordinated") {
867
+ // Unchanged behavior for homes the coordinator cannot yet adopt. Stated
868
+ // rather than implied: this is the boundary, and adoption is its own phase.
869
+ applyNativeArtifacts();
870
+ } else {
871
+ const coordinated = await withCodexWriteLock(
872
+ {
873
+ timeoutMs: options.lockTimeoutMs ?? DEFAULT_INJECT_LOCK_TIMEOUT_MS,
874
+ admitted: { authoritySnapshotId: witness.comparisonId },
875
+ readAdmissionUnderLock: () => ({
876
+ authoritySnapshotId: recomputeInjectWitness({
877
+ candidate: witness.candidate,
878
+ canonicalTargets: witness.evidence.canonicalTargets,
879
+ persistedIdentity,
880
+ generation,
881
+ observedOwnership: witness.observedOwnership,
882
+ }).comparisonId,
883
+ }),
884
+ },
885
+ (ctx) => {
886
+ /*
887
+ * Publish BEFORE touching the filesystem. `assertPublished` runs after this
888
+ * callback returns and throws unless a transition was recorded, so writing
889
+ * first would replace every file and only then fail — with SQLite rolling
890
+ * back and the filesystem staying changed.
891
+ *
892
+ * `beginTransition` returns a conflict rather than throwing, so its result
893
+ * is checked here; ignoring it would reach the same failure by a slower
894
+ * route.
895
+ */
896
+ const published = ctx.coordinator.beginTransition(
897
+ {
898
+ nativeGeneration: ctx.expectation.nativeBefore,
899
+ currentTxId: ctx.currentTxId,
900
+ },
901
+ {
902
+ txId: ctx.expectation.txId,
903
+ direction: "apply",
904
+ authoritySnapshotId: ctx.admission.authoritySnapshotId,
905
+ nextRetryAt: new Date().toISOString(),
906
+ },
907
+ );
908
+ if (published.kind !== "updated") {
909
+ throw new CodexWriteConflictError(
910
+ `The Codex transition could not be published: ${published.kind}.`,
911
+ );
912
+ }
913
+
914
+ /*
915
+ * Exact pre-images, captured under the lock and used for compensation.
916
+ *
917
+ * A rolled-back coordinator row is not a rolled-back filesystem: each
918
+ * `atomicWriteFile` is atomic alone, never across the three together, so a
919
+ * failure partway leaves earlier replacements in place. `restoreJournalState`
920
+ * cannot be the undo — it restores whichever journal occupies the path,
921
+ * which need not be the one this operation wrote.
922
+ */
923
+ const preImages = captureCodexPreImages();
924
+ try {
925
+ applyNativeArtifacts();
926
+ } catch (error) {
927
+ // Compensate, then ALWAYS throw. Returning a partial result would let the
928
+ // lock commit a row describing an apply that did not finish.
929
+ const restored = restoreCodexPreImages(preImages);
930
+ if (!restored.complete) {
931
+ throw new CodexPartialWriteError(restored.unrestored);
932
+ }
933
+ throw error;
934
+ }
935
+ return {
936
+ kind: "applied" as const,
937
+ /*
938
+ * The receipt the terminal update matches on. The transition commits
939
+ * when the callback returns, so this pair is what the post-job
940
+ * `updateCodexHistoryTransition` CASes against — an overtaken job
941
+ * cannot overwrite a winner.
942
+ */
943
+ receipt: {
944
+ nativeGeneration: ctx.expectation.nativeAfter,
945
+ currentTxId: ctx.expectation.txId,
946
+ },
947
+ };
948
+ },
949
+ );
950
+
951
+ if (coordinated.status !== "acquired") {
952
+ return codexInjectLockOutcome(coordinated);
953
+ }
954
+ transitionReceipt = coordinated.value.receipt;
955
+ }
598
956
  // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
599
957
  // the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore
600
958
  // 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 };
959
+ // History runs in a Worker under H, not on this thread.
960
+ //
961
+ // The three surfaces it touches — the SQLite rows, the backup manifest, and the
962
+ // rollout files — do not share a transaction, so a busy timeout only ever
963
+ // serialized one of them and an opposite-direction process could overtake
964
+ // through the other two. The operation is derived from admitted intent here and
965
+ // handed down fixed; the Worker never takes a direction from its caller.
966
+ const historyOutcome = await runCodexHistoryJob({
967
+ ...resolveCodexHistoryJobTarget(),
968
+ operation: deriveCodexHistoryOperation({
969
+ direction: "apply",
970
+ resumeHistory: config?.syncResumeHistory !== false,
971
+ legacyMode,
972
+ }),
973
+ });
974
+ // A blocked or failed unit is reported, not silently counted as zero work:
975
+ // `failed` is what makes the caller's message say so.
976
+ const history: { rows: number; files: number; failed?: true } =
977
+ historyOutcome.kind === "converged"
978
+ ? { rows: historyOutcome.rows, files: historyOutcome.files }
979
+ : historyOutcome.kind === "skipped"
980
+ ? { rows: 0, files: 0 }
981
+ : { rows: 0, files: 0, failed: true };
982
+
983
+ /*
984
+ * Resolve the transition this job belongs to, on the coordinated path only.
985
+ *
986
+ * `updateCodexHistoryTransition` had no production caller since it was
987
+ * written, so every completed or skipped job left the row permanently
988
+ * `pending` — the transition was published and never resolved. This is the
989
+ * first time the durable row reflects what actually happened. The CAS on the
990
+ * receipt means an overtaken job's late write loses and is not overwritten.
991
+ */
992
+ if (transitionReceipt) {
993
+ resolveCodexHistoryTransition(transitionReceipt, historyOutcome);
994
+ }
604
995
 
605
996
  const catalogMessage = catalogPath
606
997
  ? ` Codex model catalog: ${catalogPath}\n`
607
998
  : ` 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`;
999
+ const ejected = (history as { ejectedRows?: number }).ejectedRows ?? 0;
1000
+ const migratedRows = (history.rows ?? 0) + ejected;
1001
+ const historyMessage =
1002
+ config?.syncResumeHistory === false
1003
+ ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
1004
+ : history.failed
1005
+ ? legacyMode
1006
+ ? ` ⚠️ Codex resume history sync SKIPPED: the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'.\n`
1007
+ : // Honest in every caller context: the daemon retries in the background while it runs,
1008
+ // and this inject path re-runs the migration on every future start/sync anyway.
1009
+ ` ⚠️ 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`
1010
+ : legacyMode
1011
+ ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
1012
+ : migratedRows > 0
1013
+ ? ` Codex resume history: ${migratedRows} legacy opencodex-tagged thread(s) migrated back to openai (one-time).\n`
1014
+ : ` Codex resume history: untouched (threads keep their native openai tag).\n`;
622
1015
  // A user-owned root openai_base_url means we did NOT install routing — say so honestly
623
1016
  // instead of claiming the proxy route is active (catalog/fast_mode were still written).
624
1017
  if (keptUserBaseUrl) {
625
1018
  return {
626
1019
  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` +
1020
+ ...(nativeSubagentDefaultsWarning
1021
+ ? { nativeSubagentDefaultsWarning }
1022
+ : {}),
1023
+ message:
1024
+ `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` +
629
1025
  catalogMessage +
630
1026
  historyMessage +
631
1027
  managedDefaultsMessage +
@@ -639,7 +1035,8 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
639
1035
  return {
640
1036
  success: true,
641
1037
  ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
642
- message: headline +
1038
+ message:
1039
+ headline +
643
1040
  catalogMessage +
644
1041
  historyMessage +
645
1042
  managedDefaultsMessage +
@@ -657,14 +1054,20 @@ function removeOcxSection(content: string): string {
657
1054
  const filtered: string[] = [];
658
1055
  let inOcxSection = false;
659
1056
  for (const line of lines) {
660
- if (line.includes(OCX_SECTION_MARKER) || line.trim() === "[model_providers.opencodex]") {
1057
+ if (
1058
+ line.includes(OCX_SECTION_MARKER) ||
1059
+ line.trim() === "[model_providers.opencodex]"
1060
+ ) {
661
1061
  inOcxSection = true;
662
1062
  continue;
663
1063
  }
664
1064
  if (inOcxSection) {
665
1065
  // End the injected section at the next table header that ISN'T our own — exact match so a
666
1066
  // user's "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
667
- if (/^\s*\[/.test(line) && line.trim() !== "[model_providers.opencodex]") {
1067
+ if (
1068
+ /^\s*\[/.test(line) &&
1069
+ line.trim() !== "[model_providers.opencodex]"
1070
+ ) {
668
1071
  inOcxSection = false;
669
1072
  filtered.push(line);
670
1073
  }
@@ -672,7 +1075,12 @@ function removeOcxSection(content: string): string {
672
1075
  }
673
1076
  filtered.push(line);
674
1077
  }
675
- return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
1078
+ return (
1079
+ filtered
1080
+ .join("\n")
1081
+ .replace(/\n{3,}/g, "\n\n")
1082
+ .trimEnd() + "\n"
1083
+ );
676
1084
  }
677
1085
 
678
1086
  interface StripOpencodexConfigResult {
@@ -685,9 +1093,12 @@ interface StripOpencodexConfigResult {
685
1093
  * ambiguous: keep the associated value, but return the transform error so the
686
1094
  * caller cannot report a complete restore.
687
1095
  */
688
- function stripOpencodexConfigResult(content: string): StripOpencodexConfigResult {
1096
+ function stripOpencodexConfigResult(
1097
+ content: string,
1098
+ ): StripOpencodexConfigResult {
689
1099
  let out = content;
690
- const hadRootOcxProvider = readRootTomlString(out, "model_provider") === "opencodex";
1100
+ const hadRootOcxProvider =
1101
+ readRootTomlString(out, "model_provider") === "opencodex";
691
1102
  const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out);
692
1103
  out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
693
1104
  if (out.includes("[model_providers.opencodex]")) {
@@ -696,7 +1107,10 @@ function stripOpencodexConfigResult(content: string): StripOpencodexConfigResult
696
1107
  out = removeProfileSection(out);
697
1108
  // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
698
1109
  // 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");
1110
+ out = out
1111
+ .split("\n")
1112
+ .filter((l) => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l))
1113
+ .join("\n");
700
1114
  // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves
701
1115
  // them — strip on both the legacy re-tag form and the Design B injected-base-url form.
702
1116
  if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out);
@@ -715,14 +1129,19 @@ export function stripOpencodexConfig(content: string): string {
715
1129
  }
716
1130
 
717
1131
  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);
1132
+ return (
1133
+ content.includes("[model_providers.opencodex]") ||
1134
+ /^\s*model_provider\s*=\s*"opencodex"/m.test(content) ||
1135
+ hasInjectedOpenaiBaseUrl(content)
1136
+ );
721
1137
  }
722
1138
 
723
- export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): { success: boolean; message: string } {
1139
+ export function removeCodexConfig(
1140
+ options: { preserveProfile?: boolean } = {},
1141
+ ): { success: boolean; message: string } {
724
1142
  if (!existsSync(CODEX_CONFIG_PATH)) {
725
- if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
1143
+ if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
1144
+ unlinkSync(CODEX_PROFILE_PATH);
726
1145
  return {
727
1146
  success: true,
728
1147
  message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
@@ -738,15 +1157,19 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}):
738
1157
  if (had || stripped.content !== content) {
739
1158
  atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol));
740
1159
  }
741
- if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
1160
+ if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
1161
+ unlinkSync(CODEX_PROFILE_PATH);
742
1162
  const removedMessage = had
743
1163
  ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}`
744
1164
  : "opencodex not present in Codex config.";
745
1165
  if (stripped.managedDefaultsError) {
746
- const routingMessage = had ? removedMessage : "No opencodex routing was present in Codex config.";
1166
+ const routingMessage = had
1167
+ ? removedMessage
1168
+ : "No opencodex routing was present in Codex config.";
747
1169
  return {
748
1170
  success: false,
749
- message: `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` +
1171
+ message:
1172
+ `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` +
750
1173
  "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.",
751
1174
  };
752
1175
  }
@@ -761,17 +1184,66 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}):
761
1184
  * so plain `codex` works when the proxy is stopped. Called by `ocx stop`, the proxy shutdown
762
1185
  * handler, and `ocx restore`. Idempotent + atomic.
763
1186
  */
764
- export function restoreNativeCodex(): { success: boolean; message: string } {
1187
+ /**
1188
+ * Restore native Codex, running history in a Worker under H.
1189
+ *
1190
+ * Prefer this everywhere. The synchronous variant below exists only for the
1191
+ * process-exit path, where awaiting a thread is its own hazard.
1192
+ */
1193
+ export async function restoreNativeCodexAsync(): Promise<{
1194
+ success: boolean;
1195
+ message: string;
1196
+ }> {
1197
+ const inline = restoreNativeCodex({ skipHistory: true });
1198
+ const outcome = await runCodexHistoryJob({
1199
+ ...resolveCodexHistoryJobTarget(),
1200
+ operation: deriveCodexHistoryOperation({
1201
+ direction: "restore",
1202
+ // Restore always returns history to native when it runs at all; the
1203
+ // opt-out belongs to apply, which is what put opencodex there.
1204
+ resumeHistory: true,
1205
+ legacyMode: false,
1206
+ }),
1207
+ });
1208
+ const historyMsg =
1209
+ outcome.kind === "converged"
1210
+ ? outcome.rows > 0
1211
+ ? ` Resume history restored from opencodex backup (${outcome.rows} thread(s)).`
1212
+ : ""
1213
+ : outcome.kind === "skipped"
1214
+ ? ""
1215
+ : // A lock we could not take is reported, never counted as nothing to do.
1216
+ ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history database. Close Codex and run \`ocx restore\` again.`;
1217
+ return { success: inline.success, message: `${inline.message}${historyMsg}` };
1218
+ }
1219
+
1220
+ export function restoreNativeCodex(options: { skipHistory?: boolean } = {}): {
1221
+ success: boolean;
1222
+ message: string;
1223
+ } {
765
1224
  const activeProvider = currentExternalCodexModelProvider();
766
1225
  if (activeProvider) {
767
1226
  removeJournal();
768
- return { success: true, message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.` };
1227
+ return {
1228
+ success: true,
1229
+ message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`,
1230
+ };
769
1231
  }
770
1232
  const journal = restoreJournalState();
771
1233
  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();
1234
+ ? {
1235
+ success: true,
1236
+ message: "Codex config restored from opencodex journal.",
1237
+ }
1238
+ : removeCodexConfig({
1239
+ preserveProfile: journal.profileRestored || journal.profileChanged,
1240
+ });
1241
+ const owningCodexHome = getCodexHome();
1242
+ const restoredCatalog = withCatalogWriteSerialization(owningCodexHome, permit => restoreCodexCatalogWithPermit(permit, owningCodexHome));
1243
+ const cat =
1244
+ restoredCatalog.kind === "completed"
1245
+ ? restoredCatalog.value
1246
+ : { removed: 0, kept: 0, path: DEFAULT_CATALOG_PATH };
775
1247
  // Design B (loopback) steady state: threads are already tagged openai, so prove the
776
1248
  // no-op with a readonly probe instead of write-opening a DB the Codex app may hold
777
1249
  // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop).
@@ -779,11 +1251,20 @@ export function restoreNativeCodex(): { success: boolean; message: string } {
779
1251
  let skipWhenProvablyNoop = false;
780
1252
  try {
781
1253
  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;
1254
+ } catch {
1255
+ /* unreadable config: keep the conservative write-open restore */
1256
+ }
1257
+ // `skipHistory` is how the async wrapper takes this work for itself: the
1258
+ // native files come down here, and history runs in the Worker under H.
1259
+ const history = options.skipHistory
1260
+ ? { rows: 0, files: 0 }
1261
+ : syncCodexHistoryProvider("openai", undefined, undefined, {
1262
+ skipWhenProvablyNoop,
1263
+ });
1264
+ const msg =
1265
+ cat.removed > 0
1266
+ ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
1267
+ : cfg.message;
787
1268
  const historyMsg = history.failed
788
1269
  ? ` ⚠️ 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
1270
  : history.rows > 0