@iislee/opencodex 2.11.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (733) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +117 -22
  3. package/bin/ocx.mjs +245 -88
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  6. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  9. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  10. package/gui/dist/provider-icons/claude-color.svg +1 -1
  11. package/gui/dist/provider-icons/cline-color.svg +16 -0
  12. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  13. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  14. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  15. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  16. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  17. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  18. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  19. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  20. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  21. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  22. package/gui/dist/provider-icons/grok.svg +1 -1
  23. package/gui/dist/provider-icons/groq-color.svg +1 -1
  24. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  25. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  26. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  27. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  28. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  29. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  30. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  31. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  32. package/gui/dist/provider-icons/openai.svg +1 -1
  33. package/gui/dist/provider-icons/opencode.svg +2 -1
  34. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  35. package/gui/dist/provider-icons/pi.svg +2 -2
  36. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  37. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  38. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  39. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  40. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  41. package/package.json +21 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +475 -62
  44. package/src/adapters/base.ts +82 -7
  45. package/src/adapters/client-fingerprint.ts +18 -12
  46. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  47. package/src/adapters/command-code.ts +637 -0
  48. package/src/adapters/cursor/call-id.ts +44 -0
  49. package/src/adapters/cursor/catalog.ts +541 -0
  50. package/src/adapters/cursor/checkpoint-store.ts +308 -0
  51. package/src/adapters/cursor/cursor-errors.ts +144 -6
  52. package/src/adapters/cursor/discovery.ts +122 -14
  53. package/src/adapters/cursor/effort-map.ts +106 -4
  54. package/src/adapters/cursor/envelope-echo.ts +290 -0
  55. package/src/adapters/cursor/framing.ts +39 -0
  56. package/src/adapters/cursor/h2-pool.ts +123 -0
  57. package/src/adapters/cursor/http1-bidi.ts +361 -0
  58. package/src/adapters/cursor/images.ts +704 -0
  59. package/src/adapters/cursor/live-models.ts +180 -59
  60. package/src/adapters/cursor/live-transport.ts +623 -170
  61. package/src/adapters/cursor/message-mapper.ts +4 -1
  62. package/src/adapters/cursor/native-exec-common.ts +23 -2
  63. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  64. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  65. package/src/adapters/cursor/native-exec-network.ts +1 -1
  66. package/src/adapters/cursor/native-exec-shell.ts +5 -3
  67. package/src/adapters/cursor/native-exec.ts +121 -14
  68. package/src/adapters/cursor/protobuf-events.ts +829 -11
  69. package/src/adapters/cursor/protobuf-request.ts +447 -75
  70. package/src/adapters/cursor/request-builder.ts +271 -35
  71. package/src/adapters/cursor/tool-definitions.ts +252 -12
  72. package/src/adapters/cursor/tool-result-normalize.ts +115 -0
  73. package/src/adapters/cursor/transport.ts +22 -0
  74. package/src/adapters/cursor/types.ts +48 -1
  75. package/src/adapters/cursor.ts +336 -39
  76. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  77. package/src/adapters/google-antigravity-replay.ts +713 -41
  78. package/src/adapters/google-antigravity-wire.ts +48 -10
  79. package/src/adapters/google-errors.ts +44 -12
  80. package/src/adapters/google-http.ts +38 -10
  81. package/src/adapters/google-tool-schema.ts +80 -15
  82. package/src/adapters/google-truncation.ts +11 -0
  83. package/src/adapters/google.ts +742 -86
  84. package/src/adapters/identity.ts +39 -6
  85. package/src/adapters/image.ts +1 -1
  86. package/src/adapters/kiro-constants.ts +27 -0
  87. package/src/adapters/kiro-errors.ts +11 -0
  88. package/src/adapters/kiro-events.ts +19 -1
  89. package/src/adapters/kiro-thinking.ts +18 -2
  90. package/src/adapters/kiro-tools.ts +65 -17
  91. package/src/adapters/kiro.ts +270 -73
  92. package/src/adapters/mimo-free.ts +17 -0
  93. package/src/adapters/openai-chat-url.ts +11 -0
  94. package/src/adapters/openai-chat.ts +1377 -344
  95. package/src/adapters/openai-responses-url.ts +16 -0
  96. package/src/adapters/openai-responses.ts +1164 -64
  97. package/src/adapters/registry.ts +175 -0
  98. package/src/adapters/responses-tool-schema.ts +67 -0
  99. package/src/adapters/run-turn-queue.ts +36 -1
  100. package/src/adapters/tool-call-id.ts +119 -0
  101. package/src/adapters/tool-catalog-nudge.ts +105 -21
  102. package/src/adapters/xai-tool-schema.ts +436 -0
  103. package/src/adapters/xai-web-search.ts +186 -0
  104. package/src/bridge.ts +512 -92
  105. package/src/chat/inbound.ts +57 -20
  106. package/src/chat/outbound.ts +97 -33
  107. package/src/claude/agents-inject.ts +40 -10
  108. package/src/claude/context-windows.ts +37 -14
  109. package/src/claude/desktop-3p.ts +243 -9
  110. package/src/claude/gateway-cache.ts +41 -4
  111. package/src/claude/inbound.ts +72 -3
  112. package/src/claude/model-info.ts +38 -15
  113. package/src/claude/outbound.ts +88 -21
  114. package/src/cli/access.ts +46 -3
  115. package/src/cli/account-api.ts +150 -19
  116. package/src/cli/account-auth.ts +33 -6
  117. package/src/cli/account-catalog-refresh.ts +14 -0
  118. package/src/cli/account-extended.ts +648 -28
  119. package/src/cli/account-main.ts +317 -0
  120. package/src/cli/account.ts +97 -19
  121. package/src/cli/agent.ts +53 -2
  122. package/src/cli/alias.ts +66 -0
  123. package/src/cli/capabilities-command.ts +94 -0
  124. package/src/cli/capabilities.ts +496 -0
  125. package/src/cli/claude-agent-startup-sync.ts +73 -0
  126. package/src/cli/claude-desktop.ts +60 -15
  127. package/src/cli/claude.ts +129 -35
  128. package/src/cli/codex-log-guard-doctor.ts +103 -0
  129. package/src/cli/codex-shim-autorestore.ts +2 -0
  130. package/src/cli/codex-shim-readiness.ts +76 -0
  131. package/src/cli/combo.ts +8 -0
  132. package/src/cli/config-command.ts +74 -10
  133. package/src/cli/dispatch.ts +821 -0
  134. package/src/cli/doctor.ts +440 -56
  135. package/src/cli/ensure-desired-integrations.ts +152 -0
  136. package/src/cli/export-command.ts +46 -20
  137. package/src/cli/help.ts +34 -274
  138. package/src/cli/index.ts +417 -528
  139. package/src/cli/init.ts +5 -17
  140. package/src/cli/inspect.ts +230 -0
  141. package/src/cli/integrations.ts +120 -2
  142. package/src/cli/lab.ts +607 -0
  143. package/src/cli/launcher-context.ts +77 -0
  144. package/src/cli/minimax.ts +497 -0
  145. package/src/cli/models-runtime.ts +130 -2
  146. package/src/cli/models.ts +118 -16
  147. package/src/cli/observe.ts +123 -11
  148. package/src/cli/opencode.ts +4 -2
  149. package/src/cli/provider-runtime.ts +34 -3
  150. package/src/cli/provider.ts +25 -3
  151. package/src/cli/ready.ts +301 -0
  152. package/src/cli/registry.ts +476 -0
  153. package/src/cli/root.ts +86 -0
  154. package/src/cli/route-policy.ts +92 -0
  155. package/src/cli/runtime-api.ts +57 -10
  156. package/src/cli/star-prompt.ts +71 -15
  157. package/src/cli/status.ts +51 -4
  158. package/src/cli/storage.ts +234 -0
  159. package/src/cli/system-command.ts +16 -0
  160. package/src/cli/system-restart-client.ts +146 -0
  161. package/src/cli/tray-proxy.ts +153 -6
  162. package/src/cli/usage-report.ts +184 -0
  163. package/src/cli/v2.ts +137 -18
  164. package/src/cli/version-skew.ts +46 -0
  165. package/src/cli.ts +1 -1
  166. package/src/clients/config-export.ts +1442 -23
  167. package/src/clients/effective-status.ts +30 -2
  168. package/src/clients/probes/cline.ts +81 -0
  169. package/src/codex/account-label.ts +35 -1
  170. package/src/codex/account-lifecycle.ts +130 -13
  171. package/src/codex/account-namespaces.ts +49 -3
  172. package/src/codex/account-pause.ts +2 -1
  173. package/src/codex/account-priority.ts +84 -0
  174. package/src/codex/account-store.ts +29 -2
  175. package/src/codex/account-usability.ts +25 -2
  176. package/src/codex/admission.ts +256 -0
  177. package/src/codex/affinity-debug.ts +162 -0
  178. package/src/codex/app-server-processes.ts +570 -107
  179. package/src/codex/app-server-restart-service.ts +232 -0
  180. package/src/codex/auth-api.ts +893 -246
  181. package/src/codex/auth-collision.ts +5 -3
  182. package/src/codex/auth-context.ts +348 -32
  183. package/src/codex/autostart-health.ts +24 -1
  184. package/src/codex/catalog/account-models.ts +67 -0
  185. package/src/codex/catalog/aggregation.ts +86 -10
  186. package/src/codex/catalog/bundled.ts +331 -33
  187. package/src/codex/catalog/effort.ts +138 -32
  188. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  189. package/src/codex/catalog/kinds.ts +2 -0
  190. package/src/codex/catalog/metadata.ts +555 -45
  191. package/src/codex/catalog/native-models.ts +75 -0
  192. package/src/codex/catalog/parsing.ts +269 -30
  193. package/src/codex/catalog/provider-fetch.ts +1626 -144
  194. package/src/codex/catalog/sync.ts +1576 -187
  195. package/src/codex/catalog-admission.ts +199 -0
  196. package/src/codex/catalog-refresh-status.ts +105 -0
  197. package/src/codex/catalog-write-serialization.ts +242 -0
  198. package/src/codex/catalog.ts +6 -3
  199. package/src/codex/codex-write-lock.ts +386 -0
  200. package/src/codex/convergence-types.ts +614 -0
  201. package/src/codex/convergence.ts +676 -0
  202. package/src/codex/coordinator-doctor.ts +332 -0
  203. package/src/codex/custom-model-catalog-migration.ts +176 -0
  204. package/src/codex/desired-state.ts +230 -0
  205. package/src/codex/desktop-app-restart.ts +355 -0
  206. package/src/codex/features.ts +636 -39
  207. package/src/codex/generation.ts +202 -0
  208. package/src/codex/history-job.ts +436 -0
  209. package/src/codex/history-lock.ts +242 -0
  210. package/src/codex/history-manifest.ts +112 -0
  211. package/src/codex/history-migration-guardian.ts +30 -24
  212. package/src/codex/history-provider.ts +1016 -235
  213. package/src/codex/history-transition.ts +105 -0
  214. package/src/codex/history-worker.ts +223 -0
  215. package/src/codex/inject-coordination.ts +373 -0
  216. package/src/codex/inject.ts +1114 -152
  217. package/src/codex/injected-marker.ts +37 -3
  218. package/src/codex/integration-record.ts +266 -0
  219. package/src/codex/internal/catalog-writer.ts +203 -0
  220. package/src/codex/internal/history-writer.ts +81 -0
  221. package/src/codex/journal.ts +66 -4
  222. package/src/codex/log-guard/inspect.ts +524 -0
  223. package/src/codex/log-guard/lock.ts +150 -0
  224. package/src/codex/log-guard/maintenance.ts +403 -0
  225. package/src/codex/log-guard/path-safety.ts +88 -0
  226. package/src/codex/log-guard/policy.ts +44 -0
  227. package/src/codex/log-guard/processes.ts +205 -0
  228. package/src/codex/log-guard/protection.ts +489 -0
  229. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  230. package/src/codex/main-account-cache.ts +24 -0
  231. package/src/codex/main-account.ts +29 -1
  232. package/src/codex/management-convergence.ts +167 -0
  233. package/src/codex/model-cache.ts +56 -10
  234. package/src/codex/model-entitlements.ts +360 -0
  235. package/src/codex/native-main-admission.ts +47 -0
  236. package/src/codex/native-main-auth-temp.ts +187 -0
  237. package/src/codex/native-main-claim.ts +178 -0
  238. package/src/codex/native-main-lock-file.ts +162 -0
  239. package/src/codex/native-main-owner.ts +329 -0
  240. package/src/codex/native-profile-api.ts +247 -0
  241. package/src/codex/native-profile-manager.ts +1531 -0
  242. package/src/codex/native-profile-processes.ts +121 -0
  243. package/src/codex/native-profile-recovery.ts +99 -0
  244. package/src/codex/native-profile-stage-store.ts +387 -0
  245. package/src/codex/native-profile-startup.ts +622 -0
  246. package/src/codex/native-profile-store.ts +855 -0
  247. package/src/codex/native-profile-types.ts +120 -0
  248. package/src/codex/native-residue.ts +675 -0
  249. package/src/codex/paths.ts +80 -1
  250. package/src/codex/plan-from-token.ts +140 -0
  251. package/src/codex/plan.ts +40 -0
  252. package/src/codex/plugins-doctor.ts +1 -1
  253. package/src/codex/pool-rotation.ts +74 -4
  254. package/src/codex/project-config-warnings.ts +20 -6
  255. package/src/codex/prompt-journal.ts +352 -0
  256. package/src/codex/prompt-layers.ts +1313 -0
  257. package/src/codex/prompt-lock.ts +143 -0
  258. package/src/codex/prompt-text-probe.ts +238 -0
  259. package/src/codex/quota-rejection.ts +298 -0
  260. package/src/codex/quota.ts +264 -21
  261. package/src/codex/refresh.ts +11 -2
  262. package/src/codex/reset-credit-recovery.ts +1044 -0
  263. package/src/codex/routing.ts +514 -94
  264. package/src/codex/runtime.ts +159 -38
  265. package/src/codex/shim.ts +1065 -31
  266. package/src/codex/subagent-model-fallback.ts +437 -43
  267. package/src/codex/sync.ts +191 -2
  268. package/src/codex/transition-state.ts +720 -0
  269. package/src/codex/upstream-host-health.ts +368 -0
  270. package/src/codex/user-identity.ts +557 -0
  271. package/src/codex/warmup.ts +187 -81
  272. package/src/codex/write-coordination.ts +114 -0
  273. package/src/combos/failover.ts +47 -0
  274. package/src/combos/index.ts +4 -0
  275. package/src/combos/request.ts +32 -0
  276. package/src/combos/types.ts +91 -9
  277. package/src/compatibility/index.ts +26 -0
  278. package/src/compatibility/manifest.ts +253 -0
  279. package/src/compatibility/openai-responses.ts +121 -0
  280. package/src/config/atomic-write.ts +219 -0
  281. package/src/config/paths.ts +40 -0
  282. package/src/config/process-state.ts +309 -0
  283. package/src/config/provider-name.ts +24 -0
  284. package/src/config/provider-validation.ts +177 -0
  285. package/src/config/rebase-provenance.ts +68 -0
  286. package/src/config.ts +1713 -709
  287. package/src/generated/compatibility-version.json +3324 -0
  288. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  289. package/src/grok/inject.ts +16 -5
  290. package/src/grok/inspect.ts +45 -0
  291. package/src/grok/sync.ts +2 -2
  292. package/src/images/loop.ts +157 -33
  293. package/src/images/plan.ts +24 -13
  294. package/src/integrations/config-io.ts +269 -0
  295. package/src/integrations/journal.ts +315 -0
  296. package/src/integrations/merge.ts +135 -0
  297. package/src/integrations/mutation-flight.ts +71 -0
  298. package/src/integrations/native/ownership-preflight.ts +202 -0
  299. package/src/integrations/omp-yaml-source.ts +358 -0
  300. package/src/integrations/owned-refresh.ts +74 -0
  301. package/src/integrations/ownership-policy.ts +160 -0
  302. package/src/integrations/ownership.ts +155 -0
  303. package/src/integrations/registry.ts +166 -0
  304. package/src/integrations/serialize.ts +314 -0
  305. package/src/integrations/state.ts +433 -0
  306. package/src/integrations/store.ts +103 -0
  307. package/src/integrations/writer-lock.ts +98 -0
  308. package/src/integrations/writer.ts +715 -0
  309. package/src/lab/artifacts/sanitize.ts +586 -0
  310. package/src/lab/artifacts/secure-fs.ts +475 -0
  311. package/src/lab/artifacts/store.ts +310 -0
  312. package/src/lab/automation/budgets.ts +78 -0
  313. package/src/lab/automation/config-persistence.ts +256 -0
  314. package/src/lab/automation/constants.ts +39 -0
  315. package/src/lab/automation/cooldown.ts +103 -0
  316. package/src/lab/automation/dispatch.ts +211 -0
  317. package/src/lab/automation/index.ts +13 -0
  318. package/src/lab/automation/orchestrator.ts +499 -0
  319. package/src/lab/automation/persistence.ts +512 -0
  320. package/src/lab/automation/planner.ts +371 -0
  321. package/src/lab/automation/policy.ts +136 -0
  322. package/src/lab/automation/queue.ts +191 -0
  323. package/src/lab/automation/recovery.ts +24 -0
  324. package/src/lab/automation/route-context.ts +21 -0
  325. package/src/lab/automation/run-key.ts +44 -0
  326. package/src/lab/automation/runs-query.ts +34 -0
  327. package/src/lab/automation/types.ts +160 -0
  328. package/src/lab/conformance/assertion.ts +325 -0
  329. package/src/lab/conformance/digest.ts +22 -0
  330. package/src/lab/conformance/executor.ts +741 -0
  331. package/src/lab/conformance/fixture-provider.ts +27 -0
  332. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  333. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  334. package/src/lab/conformance/harness-budget.ts +47 -0
  335. package/src/lab/conformance/index.ts +5 -0
  336. package/src/lab/conformance/jcs.ts +64 -0
  337. package/src/lab/conformance/json-pointer.ts +39 -0
  338. package/src/lab/conformance/manifest.ts +180 -0
  339. package/src/lab/conformance/mcp-stub.ts +179 -0
  340. package/src/lab/conformance/negative-controls.ts +164 -0
  341. package/src/lab/conformance/observation.ts +355 -0
  342. package/src/lab/conformance/runner.ts +68 -0
  343. package/src/lab/conformance/sse-normalize.ts +59 -0
  344. package/src/lab/conformance/suite-manifest.ts +78 -0
  345. package/src/lab/conformance/types.ts +214 -0
  346. package/src/lab/constants.ts +126 -0
  347. package/src/lab/digest.ts +64 -0
  348. package/src/lab/events/errors.ts +9 -0
  349. package/src/lab/events/limits.ts +117 -0
  350. package/src/lab/events/types.ts +229 -0
  351. package/src/lab/events/validate.ts +781 -0
  352. package/src/lab/fabric/constants.ts +40 -0
  353. package/src/lab/fabric/executor.ts +492 -0
  354. package/src/lab/fabric/index.ts +80 -0
  355. package/src/lab/fabric/manifest.ts +222 -0
  356. package/src/lab/fabric/observe.ts +489 -0
  357. package/src/lab/fabric/patch.ts +79 -0
  358. package/src/lab/fabric/producer-child.ts +139 -0
  359. package/src/lab/fabric/producer-isolate.ts +276 -0
  360. package/src/lab/fabric/producer-protocol.ts +61 -0
  361. package/src/lab/fabric/scratch.ts +439 -0
  362. package/src/lab/fabric/subject.ts +106 -0
  363. package/src/lab/fabric/types.ts +134 -0
  364. package/src/lab/fabric/verifier.ts +98 -0
  365. package/src/lab/index.ts +54 -0
  366. package/src/lab/ledger/artifact-refs.ts +127 -0
  367. package/src/lab/ledger/invalidation.ts +136 -0
  368. package/src/lab/ledger/purge.ts +310 -0
  369. package/src/lab/ledger/store.ts +532 -0
  370. package/src/lab/live/credential-lease.ts +53 -0
  371. package/src/lab/live/destination.ts +155 -0
  372. package/src/lab/live/executor.ts +336 -0
  373. package/src/lab/live/inert-tools.ts +56 -0
  374. package/src/lab/live/manifest.ts +85 -0
  375. package/src/lab/live/mcp-loopback.ts +57 -0
  376. package/src/lab/live/runner.ts +19 -0
  377. package/src/lab/live/sandbox.ts +61 -0
  378. package/src/lab/live/suite-manifest.ts +41 -0
  379. package/src/lab/live/transport.ts +118 -0
  380. package/src/lab/live/types.ts +197 -0
  381. package/src/lab/observe/from-conformance.ts +301 -0
  382. package/src/lab/observe/from-live.ts +117 -0
  383. package/src/lab/paths.ts +153 -0
  384. package/src/lab/projection/rebuild.ts +495 -0
  385. package/src/lab/projection/schema.ts +135 -0
  386. package/src/lab/projection/verdicts.ts +474 -0
  387. package/src/lab/projection/verification.ts +412 -0
  388. package/src/lab/public/bundle.ts +217 -0
  389. package/src/lab/public/community-authority.ts +175 -0
  390. package/src/lab/public/community-files.ts +29 -0
  391. package/src/lab/public/community.ts +479 -0
  392. package/src/lab/public/file-safety.ts +155 -0
  393. package/src/lab/public/ids.ts +26 -0
  394. package/src/lab/public/index.ts +16 -0
  395. package/src/lab/public/mutation-lock.ts +424 -0
  396. package/src/lab/public/operator.ts +353 -0
  397. package/src/lab/public/origin-purge.ts +79 -0
  398. package/src/lab/public/origin.ts +203 -0
  399. package/src/lab/public/privacy.ts +143 -0
  400. package/src/lab/public/private-file.ts +261 -0
  401. package/src/lab/public/project.ts +124 -0
  402. package/src/lab/public/purge-test-fault.ts +21 -0
  403. package/src/lab/public/purge.ts +223 -0
  404. package/src/lab/public/registry.ts +44 -0
  405. package/src/lab/public/revocation.ts +252 -0
  406. package/src/lab/public/signature.ts +243 -0
  407. package/src/lab/public/storage.ts +105 -0
  408. package/src/lab/public/strict-json.ts +206 -0
  409. package/src/lab/public/time.ts +26 -0
  410. package/src/lab/public/types.ts +172 -0
  411. package/src/lab/public/validate.ts +391 -0
  412. package/src/lab/query/catalog.ts +101 -0
  413. package/src/lab/query/connection.ts +107 -0
  414. package/src/lab/query/constants.ts +4 -0
  415. package/src/lab/query/cursor.ts +132 -0
  416. package/src/lab/query/dto-map.ts +277 -0
  417. package/src/lab/query/errors.ts +22 -0
  418. package/src/lab/query/freshness.ts +53 -0
  419. package/src/lab/query/index.ts +45 -0
  420. package/src/lab/query/latest-observation.ts +59 -0
  421. package/src/lab/query/passive-production.ts +159 -0
  422. package/src/lab/query/queries.ts +444 -0
  423. package/src/lab/query/types.ts +266 -0
  424. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  425. package/src/lab/subject/installation-salt.ts +112 -0
  426. package/src/lab/subject/protocol-subject.ts +80 -0
  427. package/src/lab/subject/route-subject.ts +74 -0
  428. package/src/lib/admin-secrets.ts +24 -0
  429. package/src/lib/app-owned-memory-stores.ts +22 -0
  430. package/src/lib/bounded-body.ts +167 -11
  431. package/src/lib/bun-runtime.ts +125 -12
  432. package/src/lib/bun-stream-caps.ts +13 -9
  433. package/src/lib/codex-restart-contract.ts +120 -0
  434. package/src/lib/config-ownership.ts +26 -2
  435. package/src/lib/destination-policy.ts +65 -1
  436. package/src/lib/errors.ts +80 -5
  437. package/src/lib/fabric-task-execution-authority.ts +7 -0
  438. package/src/lib/fabric-task-host.ts +29 -0
  439. package/src/lib/lab-activation.ts +223 -0
  440. package/src/lib/lab-live-execution-authority.ts +13 -0
  441. package/src/lib/lab-live-host.ts +30 -0
  442. package/src/lib/lab-live-pinned-sender.ts +56 -0
  443. package/src/lib/lab-live-route-production.ts +130 -0
  444. package/src/lib/lab-passive-linker-registration.ts +26 -0
  445. package/src/lib/local-management-attestation.ts +51 -0
  446. package/src/lib/local-management-capability.ts +100 -0
  447. package/src/lib/local-provider-reload-contract.ts +100 -0
  448. package/src/lib/optional-shutdown-hooks.ts +57 -0
  449. package/src/lib/package-tree-integrity.ts +101 -0
  450. package/src/lib/pinned-http.ts +145 -26
  451. package/src/lib/process-control.ts +6 -2
  452. package/src/lib/provider-outbound.ts +49 -9
  453. package/src/lib/redact.ts +419 -3
  454. package/src/lib/self-launch-argv.ts +15 -0
  455. package/src/lib/server-resource-ownership.ts +71 -0
  456. package/src/lib/service-secrets.ts +15 -0
  457. package/src/lib/shadow-call.ts +35 -4
  458. package/src/lib/sse-decoder.ts +41 -0
  459. package/src/lib/state-store-registrations.ts +10 -2
  460. package/src/lib/system-restart-contract.ts +73 -0
  461. package/src/lib/token-estimate.ts +19 -2
  462. package/src/lib/tool-argument-integers.ts +253 -0
  463. package/src/lib/translator-budget.ts +44 -0
  464. package/src/lib/upstream-http-version.ts +57 -0
  465. package/src/lib/upstream-reachability.ts +95 -0
  466. package/src/lib/upstream-retry.ts +156 -3
  467. package/src/lib/windows-atomic-replace.ts +155 -0
  468. package/src/lib/windows-elevation.ts +70 -2
  469. package/src/lib/windows-secret-acl.ts +409 -69
  470. package/src/lib/windows-service-wrappers.ts +72 -0
  471. package/src/lib/windows-text.ts +106 -0
  472. package/src/lib/windows-user-principal.ts +341 -0
  473. package/src/lib/winsw.ts +33 -5
  474. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  475. package/src/oauth/account-import/index.ts +15 -0
  476. package/src/oauth/account-import/parser.ts +83 -0
  477. package/src/oauth/account-import/registry.ts +18 -0
  478. package/src/oauth/account-import/service.ts +75 -0
  479. package/src/oauth/account-import/types.ts +91 -0
  480. package/src/oauth/anthropic.ts +12 -1
  481. package/src/oauth/callback-server.ts +30 -4
  482. package/src/oauth/chatgpt.ts +12 -1
  483. package/src/oauth/cline.ts +203 -0
  484. package/src/oauth/command-code.ts +239 -0
  485. package/src/oauth/cursor.ts +46 -5
  486. package/src/oauth/generic-account-failover.ts +231 -0
  487. package/src/oauth/google-antigravity.ts +35 -3
  488. package/src/oauth/health.ts +20 -12
  489. package/src/oauth/index.ts +462 -71
  490. package/src/oauth/key-providers.ts +25 -0
  491. package/src/oauth/kimi.ts +25 -3
  492. package/src/oauth/kiro.ts +95 -6
  493. package/src/oauth/local-token-detect.ts +13 -2
  494. package/src/oauth/log.ts +3 -1
  495. package/src/oauth/login-cli.ts +88 -28
  496. package/src/oauth/nous.ts +798 -0
  497. package/src/oauth/open-browser-choice.ts +26 -0
  498. package/src/oauth/store.ts +133 -26
  499. package/src/oauth/token-guardian.ts +9 -3
  500. package/src/oauth/types.ts +15 -0
  501. package/src/pi/models.ts +2 -2
  502. package/src/providers/alibaba-region-migration.ts +1 -1
  503. package/src/providers/antigravity-models.ts +521 -31
  504. package/src/providers/auto-compact-budget.ts +65 -0
  505. package/src/providers/base-url-choices.ts +10 -0
  506. package/src/providers/codex-capacity.ts +292 -0
  507. package/src/providers/command-code-efforts.ts +176 -0
  508. package/src/providers/context-cap.ts +26 -8
  509. package/src/providers/cursor-pool.ts +72 -0
  510. package/src/providers/default-aliases.ts +65 -0
  511. package/src/providers/derive.ts +281 -6
  512. package/src/providers/fastwire.ts +507 -0
  513. package/src/providers/free-directory.ts +10 -7
  514. package/src/providers/google-vertex-location.ts +14 -0
  515. package/src/providers/key-failover.ts +71 -3
  516. package/src/providers/label.ts +35 -2
  517. package/src/providers/model-discovery-limits.ts +16 -0
  518. package/src/providers/model-discovery.ts +115 -22
  519. package/src/providers/model-presets.ts +119 -0
  520. package/src/providers/model-rename-migration.ts +255 -0
  521. package/src/providers/model-rename-startup.ts +28 -0
  522. package/src/providers/new-model-policy.ts +146 -0
  523. package/src/providers/openai-sidecar.ts +72 -4
  524. package/src/providers/openai-tier-startup.ts +31 -2
  525. package/src/providers/openai-tiers.ts +119 -4
  526. package/src/providers/openai-virtual-models.ts +1 -0
  527. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  528. package/src/providers/provider-id-rewrite.ts +30 -0
  529. package/src/providers/quota.ts +1379 -40
  530. package/src/providers/registry.ts +1545 -112
  531. package/src/providers/request-pacing.ts +310 -0
  532. package/src/providers/service-tier.ts +277 -0
  533. package/src/providers/slug-codec.ts +94 -6
  534. package/src/providers/static-model-discovery.ts +86 -0
  535. package/src/providers/xai-responses-opt-in.ts +15 -0
  536. package/src/providers/xai-transport.ts +32 -4
  537. package/src/reasoning-effort.ts +68 -3
  538. package/src/responses/apply-patch-envelope.ts +63 -0
  539. package/src/responses/code-mode-helper-compat.ts +50 -0
  540. package/src/responses/compaction.ts +26 -1
  541. package/src/responses/custom-tool-compat.ts +384 -0
  542. package/src/responses/hosted-tool-policy.ts +9 -0
  543. package/src/responses/namespace-tool-compat.ts +435 -0
  544. package/src/responses/parser.ts +225 -38
  545. package/src/responses/provider-continuation.ts +98 -0
  546. package/src/responses/provider-opaque-metadata.ts +73 -0
  547. package/src/responses/reasoning-envelope.ts +9 -1
  548. package/src/responses/reasoning-replay-cache.ts +504 -0
  549. package/src/responses/schema.ts +12 -2
  550. package/src/responses/spill-store.ts +75 -10
  551. package/src/responses/state.ts +659 -31
  552. package/src/responses/thought-signature-replay.ts +364 -0
  553. package/src/responses/tool-search-compat.ts +301 -0
  554. package/src/responses/truncated-stop-reason.ts +60 -0
  555. package/src/responses/turn-termination.ts +107 -0
  556. package/src/router.ts +405 -31
  557. package/src/routing/analytics.ts +379 -0
  558. package/src/routing/capability.ts +244 -0
  559. package/src/routing/compatibility/assemble.ts +73 -0
  560. package/src/routing/compatibility/behavior.ts +278 -0
  561. package/src/routing/compatibility/catalog.ts +99 -0
  562. package/src/routing/compatibility/endpoint.ts +52 -0
  563. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  564. package/src/routing/compatibility/policy.ts +181 -0
  565. package/src/routing/compatibility/provider-slot.ts +56 -0
  566. package/src/routing/compatibility/reader.ts +110 -0
  567. package/src/routing/compatibility/subject.ts +191 -0
  568. package/src/routing/compatibility/types.ts +64 -0
  569. package/src/routing/compatibility/version.ts +104 -0
  570. package/src/routing/cost.ts +77 -0
  571. package/src/routing/evaluator.ts +495 -0
  572. package/src/routing/health.ts +412 -0
  573. package/src/routing/history/cursor.ts +43 -0
  574. package/src/routing/history/indexer.ts +605 -0
  575. package/src/routing/history/schema.ts +72 -0
  576. package/src/routing/profile-namespace.ts +15 -0
  577. package/src/routing/profile.ts +547 -0
  578. package/src/routing/quota.ts +155 -0
  579. package/src/routing/request-evidence.ts +45 -0
  580. package/src/routing/trace.ts +776 -0
  581. package/src/server/adapter-resolve.ts +2 -29
  582. package/src/server/auth-cors.ts +289 -48
  583. package/src/server/background-lifecycle.ts +182 -0
  584. package/src/server/chat-completions.ts +154 -72
  585. package/src/server/chat-native-sse.ts +331 -0
  586. package/src/server/chat-native.ts +445 -0
  587. package/src/server/claude-messages.ts +160 -44
  588. package/src/server/direct-local-http.ts +347 -0
  589. package/src/server/effort-policy.ts +18 -0
  590. package/src/server/github-copilot-responses-repair.ts +338 -0
  591. package/src/server/gui-static.ts +39 -10
  592. package/src/server/images.ts +94 -12
  593. package/src/server/index.ts +1021 -190
  594. package/src/server/lifecycle.ts +336 -14
  595. package/src/server/live.ts +136 -17
  596. package/src/server/local-management-read-client.ts +90 -0
  597. package/src/server/local-provider-reload-client.ts +137 -0
  598. package/src/server/management/agent-settings-routes.ts +433 -116
  599. package/src/server/management/api-key-usage.ts +31 -5
  600. package/src/server/management/body.ts +6 -0
  601. package/src/server/management/codex-prompt-routes.ts +570 -0
  602. package/src/server/management/combo-routes.ts +64 -25
  603. package/src/server/management/config-routes.ts +511 -51
  604. package/src/server/management/context.ts +89 -2
  605. package/src/server/management/integration-routes.ts +498 -0
  606. package/src/server/management/lab-automation-routes.ts +206 -0
  607. package/src/server/management/lab-routes.ts +563 -0
  608. package/src/server/management/logs-usage-routes.ts +127 -52
  609. package/src/server/management/model-routes.ts +461 -133
  610. package/src/server/management/model-rows.ts +167 -0
  611. package/src/server/management/native-integration-routes.ts +770 -0
  612. package/src/server/management/oauth-account-routes.ts +90 -8
  613. package/src/server/management/provider-capability-config.ts +48 -0
  614. package/src/server/management/provider-routes.ts +836 -159
  615. package/src/server/management/request-history-routes.ts +191 -0
  616. package/src/server/management/route-registry.ts +332 -0
  617. package/src/server/management/routing-analytics-routes.ts +74 -0
  618. package/src/server/management/routing-profile-routes.ts +380 -0
  619. package/src/server/management/shared.ts +27 -11
  620. package/src/server/management/sidebar-routes.ts +47 -31
  621. package/src/server/management/storage-log-guard-routes.ts +186 -0
  622. package/src/server/management/sync-response.ts +69 -0
  623. package/src/server/management/system-restart.ts +289 -33
  624. package/src/server/management/system-routes.ts +77 -0
  625. package/src/server/management/usage-summary-cache.ts +9 -1
  626. package/src/server/management/vision-sidecar-options.ts +167 -0
  627. package/src/server/management/web-search-sidecar-options.ts +120 -0
  628. package/src/server/management-api.ts +126 -23
  629. package/src/server/management-auth.ts +220 -5
  630. package/src/server/passive-route-linker.ts +66 -0
  631. package/src/server/port-reclaim.ts +1 -1
  632. package/src/server/ports.ts +41 -1
  633. package/src/server/proxy-liveness.ts +161 -10
  634. package/src/server/readiness.ts +99 -0
  635. package/src/server/relay-eager.ts +221 -54
  636. package/src/server/relay.ts +479 -87
  637. package/src/server/request-decompress.ts +113 -6
  638. package/src/server/request-log-conversation.ts +51 -0
  639. package/src/server/request-log.ts +298 -31
  640. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  641. package/src/server/responses/agent-task-recovery.ts +465 -0
  642. package/src/server/responses/codex-auth-error.ts +55 -0
  643. package/src/server/responses/collaboration.ts +239 -36
  644. package/src/server/responses/combo-stream-preflight.ts +178 -0
  645. package/src/server/responses/compact.ts +444 -72
  646. package/src/server/responses/core.ts +3579 -436
  647. package/src/server/responses/empty-completion-guard.ts +333 -0
  648. package/src/server/responses/encrypted-payload.ts +62 -39
  649. package/src/server/responses/fetch-helpers.ts +89 -99
  650. package/src/server/responses/input-admission.ts +187 -0
  651. package/src/server/responses/pacing-overload.ts +13 -0
  652. package/src/server/responses/passthrough-error.ts +33 -9
  653. package/src/server/responses/policy-fallback.ts +178 -0
  654. package/src/server/responses/responses-field-backfill.ts +343 -0
  655. package/src/server/responses/terminal-guard.ts +26 -5
  656. package/src/server/responses/upstream-error.ts +5 -0
  657. package/src/server/responses/ws-upstream.ts +381 -0
  658. package/src/server/responses-custom-tool-repair.ts +363 -0
  659. package/src/server/responses-item-id-repair.ts +54 -6
  660. package/src/server/responses-json-events.ts +90 -0
  661. package/src/server/responses-model-rewrite.ts +29 -0
  662. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  663. package/src/server/responses-snapshot-repair.ts +621 -0
  664. package/src/server/responses-terminal-repair.ts +363 -0
  665. package/src/server/responses-tool-search-repair.ts +267 -0
  666. package/src/server/responses-undeclared-tool-guard.ts +376 -0
  667. package/src/server/responses.ts +18 -2
  668. package/src/server/search.ts +78 -13
  669. package/src/server/sse-frame-buffer.ts +319 -0
  670. package/src/server/sse-payload-rewrite.ts +110 -22
  671. package/src/server/startup-action-control.ts +8 -1
  672. package/src/server/startup-health-cache.ts +19 -1
  673. package/src/server/system-env.ts +80 -9
  674. package/src/server/ws-bridge.ts +53 -40
  675. package/src/service-manager-probe.ts +892 -0
  676. package/src/service.ts +1153 -94
  677. package/src/sidecar/auth.ts +92 -0
  678. package/src/sidecar/candidates.ts +83 -0
  679. package/src/storage/cleanup.ts +2 -2
  680. package/src/storage/policy-job.ts +14 -4
  681. package/src/storage/policy.ts +88 -23
  682. package/src/storage/scanner.ts +1 -1
  683. package/src/storage/worker-lifecycle.ts +14 -14
  684. package/src/tray/windows-tray.ps1 +83 -9
  685. package/src/tray/windows.ts +43 -16
  686. package/src/types/accounts.ts +37 -0
  687. package/src/types/config.ts +911 -0
  688. package/src/types/provider.ts +614 -0
  689. package/src/types/request.ts +397 -0
  690. package/src/types/tools.ts +236 -0
  691. package/src/types/wire.ts +80 -0
  692. package/src/types.ts +107 -1237
  693. package/src/update/index.ts +37 -23
  694. package/src/update/job.ts +445 -68
  695. package/src/update/notify.ts +12 -6
  696. package/src/update/npm-cache-preflight.d.mts +47 -0
  697. package/src/update/npm-cache-preflight.mjs +201 -0
  698. package/src/update/transactional-install.d.mts +22 -0
  699. package/src/update/transactional-install.mjs +266 -0
  700. package/src/usage/cost.ts +0 -0
  701. package/src/usage/expected-prices.ts +287 -16
  702. package/src/usage/log.ts +666 -50
  703. package/src/usage/summary.ts +388 -17
  704. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  705. package/src/usage/user-cost-overlays.ts +314 -0
  706. package/src/vision/anthropic-describe.ts +10 -6
  707. package/src/vision/backends.ts +97 -0
  708. package/src/vision/describe.ts +27 -16
  709. package/src/vision/eligibility.ts +250 -0
  710. package/src/vision/index.ts +238 -24
  711. package/src/vision/reasoning.ts +55 -0
  712. package/src/vision/routed-describe.ts +175 -0
  713. package/src/vision/timeout-bounds.ts +9 -0
  714. package/src/web-search/anthropic-executor.ts +13 -7
  715. package/src/web-search/backends.ts +108 -0
  716. package/src/web-search/exa-executor.ts +119 -0
  717. package/src/web-search/executor.ts +21 -6
  718. package/src/web-search/gemini-executor.ts +141 -0
  719. package/src/web-search/index.ts +150 -15
  720. package/src/web-search/loop.ts +284 -54
  721. package/src/web-search/parse.ts +125 -30
  722. package/src/web-search/sources.ts +60 -0
  723. package/src/web-search/xai-executor.ts +219 -0
  724. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  725. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  726. package/gui/dist/provider-icons/antigravity.svg +0 -1
  727. package/gui/dist/provider-icons/claude.svg +0 -1
  728. package/gui/dist/provider-icons/copilot.svg +0 -1
  729. package/gui/dist/provider-icons/cursor.svg +0 -2
  730. package/gui/dist/provider-icons/gemini.svg +0 -1
  731. package/gui/dist/provider-icons/grok-color.svg +0 -1
  732. package/gui/dist/provider-icons/kiro.svg +0 -14
  733. package/src/cli/internal-dispatch.ts +0 -20
@@ -1,10 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs";
2
+ import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { zstdDecompressSync } from "node:zlib";
5
5
  import { Database } from "bun:sqlite";
6
- import { CODEX_HOME } from "./paths";
6
+ import { resolveCodexStateDbPath } from "./paths";
7
7
  import { atomicWriteFile, getConfigDir } from "../config";
8
+ import {
9
+ CODEX_HISTORY_RESUMABLE_SOURCES,
10
+ codexHistoryBackupId,
11
+ sameCodexHistoryPath,
12
+ validateCodexHistoryBackupManifest,
13
+ type CodexHistoryBackupEntry,
14
+ type CodexHistoryBackupManifest,
15
+ } from "./history-manifest";
8
16
 
9
17
  /**
10
18
  * Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore.
@@ -13,14 +21,16 @@ import { atomicWriteFile, getConfigDir } from "../config";
13
21
  */
14
22
  export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
15
23
 
16
- const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
17
- function historyBackupPathFor(stateDbPath: string): string {
18
- const normalized = process.platform === "win32" ? resolve(stateDbPath).toLowerCase() : resolve(stateDbPath);
19
- const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
20
- return join(getConfigDir(), `codex-history-backup-${id}.json`);
24
+ /**
25
+ * The manifest that shadows one state database.
26
+ *
27
+ * Exported because the history job must resolve it at CALL time for a Worker
28
+ * that does not inherit this module's load-time constants — and must resolve it
29
+ * the same way, since a manifest addressed differently is a different manifest.
30
+ */
31
+ export function historyBackupPathFor(stateDbPath: string): string {
32
+ return join(getConfigDir(), `codex-history-backup-${codexHistoryBackupId(stateDbPath)}.json`);
21
33
  }
22
- const HISTORY_BACKUP_PATH = historyBackupPathFor(STATE_DB_PATH);
23
- const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
24
34
 
25
35
  /**
26
36
  * Open the live `state_5.sqlite` the way the Codex app expects a *secondary* writer to behave:
@@ -64,10 +74,10 @@ function openStateDb(stateDbPath: string): Database {
64
74
  * concurrently. We do not touch mtime: a fresh mtime is correct here (the app uses mtime as the
65
75
  * rollout's updated_at), and forcing it backwards could hide a real edit from list ordering.
66
76
  */
67
- function appendRolloutLine(path: string, line: string): void {
77
+ function appendRolloutLine(path: string, line: string): Buffer {
68
78
  const fd = openSync(path, "a");
79
+ const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8");
69
80
  try {
70
- const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8");
71
81
  let offset = 0;
72
82
  while (offset < buf.length) {
73
83
  offset += writeSync(fd, buf, offset, buf.length - offset, null);
@@ -76,6 +86,7 @@ function appendRolloutLine(path: string, line: string): void {
76
86
  } finally {
77
87
  closeSync(fd);
78
88
  }
89
+ return buf;
79
90
  }
80
91
 
81
92
  /**
@@ -93,78 +104,141 @@ function appendRolloutLine(path: string, line: string): void {
93
104
  * insignificant JSON whitespace. We therefore replace the provider value and pad the removed bytes
94
105
  * with spaces so the line's byte length is unchanged. Equal length means we can write at offset 0
95
106
  * with no truncate and no inode swap, so this composes safely with the app's cached append handle.
96
- * Only length-preserving shrinks are handled (e.g. "opencodex" -> "openai"); callers that would
97
- * grow the value fall back to append-only, which is correct for the opencodex direction.
107
+ * A previous shrink leaves JSON whitespace in the token slot. That padding is part of the slot,
108
+ * so an exact restore can later grow "openai" back to "opencodex" without moving any bytes.
98
109
  *
99
- * Returns true when line 1 was patched, false when it could not be done safely (missing file,
100
- * non-`session_meta` first line, id mismatch, value already correct, or a length-growing change).
110
+ * Distinguishes an already-correct line from an unsafe one so exact restore never consumes its
111
+ * manifest after only the trailing metadata was repaired.
101
112
  */
102
- function patchFirstLineProviderInPlace(path: string, expectedId: string, provider: string): boolean {
103
- if (!existsSync(path)) return false;
104
- const fd = openSync(path, "r+");
113
+ type FirstLineProviderResult = "current" | "patched" | "unsafe";
114
+
115
+ type FirstLineProviderPlan =
116
+ | { readonly state: "current" }
117
+ | { readonly state: "patchable"; readonly patchedLine: string }
118
+ | { readonly state: "unsafe" };
119
+
120
+ function readFirstRolloutLine(fd: number): string | null {
121
+ // session_meta lines embed base_instructions and can be tens of KB; grow until the line
122
+ // actually ends rather than imposing a small fixed probe that would reject valid history.
123
+ const CHUNK = 1 << 16;
124
+ const MAX_FIRST_LINE = 1 << 24;
125
+ let collected = Buffer.alloc(0);
126
+ let nlIndex = -1;
127
+ let pos = 0;
128
+ while (nlIndex === -1) {
129
+ const chunk = Buffer.alloc(CHUNK);
130
+ const read = readSync(fd, chunk, 0, CHUNK, pos);
131
+ if (read === 0) break;
132
+ collected = Buffer.concat([collected, chunk.subarray(0, read)]);
133
+ nlIndex = collected.indexOf(0x0a);
134
+ pos += read;
135
+ if (collected.length > MAX_FIRST_LINE) return null;
136
+ }
137
+ return nlIndex === -1 ? null : collected.subarray(0, nlIndex).toString("utf8");
138
+ }
139
+
140
+ function planFirstLineProvider(firstLine: string, expectedId: string, provider: string): FirstLineProviderPlan {
141
+ const meta = parseSessionMetaLine(firstLine);
142
+ if (!meta || meta.record.payload.id !== expectedId) return { state: "unsafe" };
143
+ if (meta.record.payload.model_provider === provider) return { state: "current" };
144
+
145
+ // Include JSON whitespace after the value. A prior length-preserving shrink stores its spare
146
+ // bytes there, so the exact reverse restore may grow back into that padding.
147
+ const match = firstLine.match(/"model_provider"\s*:\s*"([^"\\]*)"[ \t]*/);
148
+ if (!match || match.index === undefined) return { state: "unsafe" };
149
+ const oldToken = match[0];
150
+ const newCore = `"model_provider":"${provider}"`;
151
+ if (Buffer.byteLength(newCore, "utf8") > Buffer.byteLength(oldToken, "utf8")) return { state: "unsafe" };
152
+ const pad = " ".repeat(Buffer.byteLength(oldToken, "utf8") - Buffer.byteLength(newCore, "utf8"));
153
+ const patchedLine = firstLine.slice(0, match.index) + newCore + pad + firstLine.slice(match.index + oldToken.length);
154
+ if (Buffer.byteLength(patchedLine, "utf8") !== Buffer.byteLength(firstLine, "utf8")) return { state: "unsafe" };
155
+ const reparsed = parseSessionMetaLine(patchedLine);
156
+ if (!reparsed
157
+ || reparsed.record.payload.id !== expectedId
158
+ || reparsed.record.payload.model_provider !== provider) return { state: "unsafe" };
159
+ return { state: "patchable", patchedLine };
160
+ }
161
+
162
+ function inspectFirstLineProvider(path: string, expectedId: string, provider: string): "current" | "patchable" | "unsafe" {
163
+ if (!existsSync(path)) return "unsafe";
164
+ const fd = openSync(path, "r");
105
165
  try {
106
- // Read the first line by growing the probe until we hit a newline. session_meta lines embed
107
- // base_instructions and can be tens of KB; a fixed cap would silently skip the in-place patch
108
- // (and fall back to append-only, re-opening the first-line-clone resurrection gap), so we read
109
- // until the line actually ends rather than guessing a ceiling.
110
- const CHUNK = 1 << 16;
111
- const MAX_FIRST_LINE = 1 << 24; // 16 MiB hard stop so a newline-less/corrupt file can't OOM us.
112
- let collected = Buffer.alloc(0);
113
- let nlIndex = -1;
114
- let pos = 0;
115
- while (nlIndex === -1) {
116
- const chunk = Buffer.alloc(CHUNK);
117
- const read = readSync(fd, chunk, 0, CHUNK, pos);
118
- if (read === 0) break; // EOF with no newline: single-line file, skip
119
- collected = Buffer.concat([collected, chunk.subarray(0, read)]);
120
- nlIndex = collected.indexOf(0x0a);
121
- pos += read;
122
- if (collected.length > MAX_FIRST_LINE) return false;
123
- }
124
- if (nlIndex === -1) return false; // no newline anywhere: skip
125
- const firstLine = collected.subarray(0, nlIndex).toString("utf8");
166
+ const firstLine = readFirstRolloutLine(fd);
167
+ return firstLine === null ? "unsafe" : planFirstLineProvider(firstLine, expectedId, provider).state;
168
+ } finally {
169
+ closeSync(fd);
170
+ }
171
+ }
126
172
 
173
+ function readFirstLineProviderValue(path: string, expectedId: string): string | null {
174
+ if (!existsSync(path)) return null;
175
+ const fd = openSync(path, "r");
176
+ try {
177
+ const firstLine = readFirstRolloutLine(fd);
178
+ if (firstLine === null) return null;
127
179
  const meta = parseSessionMetaLine(firstLine);
128
- if (!meta) return false;
129
- if (meta.record.payload.id !== expectedId) return false;
130
- if (meta.record.payload.model_provider === provider) return false;
131
-
132
- // Locate the exact `"model_provider":"<value>"` token (allowing whitespace after the colon).
133
- const match = firstLine.match(/"model_provider"\s*:\s*"([^"\\]*)"/);
134
- if (!match || match.index === undefined) return false;
135
- const oldToken = match[0];
136
- const newCore = `"model_provider":"${provider}"`;
137
- if (Buffer.byteLength(newCore, "utf8") > Buffer.byteLength(oldToken, "utf8")) return false; // grow: not length-preserving
138
- const pad = " ".repeat(Buffer.byteLength(oldToken, "utf8") - Buffer.byteLength(newCore, "utf8"));
139
- const newToken = `${newCore}${pad}`;
140
-
141
- const patchedLine = firstLine.slice(0, match.index) + newToken + firstLine.slice(match.index + oldToken.length);
142
- // Length must be identical so the trailing bytes (newline + rest of file) are untouched.
143
- if (Buffer.byteLength(patchedLine, "utf8") !== Buffer.byteLength(firstLine, "utf8")) return false;
144
- // Sanity: the patched line must still parse and carry the new provider.
145
- const reparsed = parseSessionMetaLine(patchedLine);
146
- if (!reparsed || reparsed.record.payload.model_provider !== provider) return false;
147
-
148
- const out = Buffer.from(patchedLine, "utf8");
180
+ if (!meta || meta.record.payload.id !== expectedId) return null;
181
+ return typeof meta.record.payload.model_provider === "string"
182
+ ? meta.record.payload.model_provider
183
+ : null;
184
+ } finally {
185
+ closeSync(fd);
186
+ }
187
+ }
188
+
189
+ function patchFirstLineProviderInPlace(path: string, expectedId: string, provider: string): FirstLineProviderResult {
190
+ if (!existsSync(path)) return "unsafe";
191
+ const fd = openSync(path, "r+");
192
+ try {
193
+ const firstLine = readFirstRolloutLine(fd);
194
+ if (firstLine === null) return "unsafe";
195
+ const plan = planFirstLineProvider(firstLine, expectedId, provider);
196
+ if (plan.state === "unsafe") return "unsafe";
197
+ if (plan.state === "current") return "current";
198
+ const out = Buffer.from(plan.patchedLine, "utf8");
149
199
  let offset = 0;
150
200
  while (offset < out.length) {
151
201
  offset += writeSync(fd, out, offset, out.length - offset, offset);
152
202
  }
153
203
  try { fsyncSync(fd); } catch { /* best-effort durability */ }
154
- return true;
204
+ return "patched";
155
205
  } finally {
156
206
  closeSync(fd);
157
207
  }
158
208
  }
159
209
 
160
- type CodexHistoryProvider = "openai" | "opencodex";
210
+ export type CodexHistoryProvider = "openai" | "opencodex";
211
+
212
+ export type CodexHistoryFailureReason = "busy" | "permission" | "integrity";
213
+
214
+ class CodexHistoryIntegrityError extends Error {
215
+ constructor(
216
+ code: string,
217
+ readonly progress: { readonly rows: number; readonly files: number } = { rows: 0, files: 0 },
218
+ ) {
219
+ super(code);
220
+ this.name = "CodexHistoryIntegrityError";
221
+ }
222
+ }
223
+
224
+ function integrityFailureResult(error: CodexHistoryIntegrityError): CodexHistorySyncResult {
225
+ return {
226
+ rows: error.progress.rows,
227
+ files: error.progress.files,
228
+ failed: true,
229
+ failureReason: "integrity",
230
+ };
231
+ }
161
232
 
162
233
  export interface CodexHistorySyncResult {
234
+ /** Rows/files changed before a last-moment integrity race; may be nonzero with `failed`. */
163
235
  rows: number;
164
236
  files: number;
165
237
  ejectedRows?: number;
166
238
  /** Set when a lock/busy error survived retries and the sync was SKIPPED, not empty. */
167
239
  failed?: true;
240
+ /** Why the retry budget was exhausted when `failed` is set. */
241
+ failureReason?: CodexHistoryFailureReason;
168
242
  }
169
243
 
170
244
  interface ThreadRow {
@@ -175,49 +249,211 @@ interface ThreadRow {
175
249
  has_user_event: number;
176
250
  }
177
251
 
178
- interface BackupEntry {
179
- id: string;
180
- rolloutPath: string;
181
- modelProvider: string;
182
- source: string;
183
- hasUserEvent: number;
252
+ interface RestoreRowSnapshot extends ThreadRow {
253
+ first_user_message: string | null;
184
254
  }
185
255
 
186
- interface BackupManifest {
187
- version: 1;
188
- stateDbPath?: string;
189
- entries: Record<string, BackupEntry>;
256
+ interface ApplyRowSnapshot extends ThreadRow {
257
+ first_user_message: string | null;
190
258
  }
191
259
 
192
- interface NativeRestoreTarget {
193
- modelProvider: string;
194
- source: string;
195
- hasUserEvent: number;
260
+ function hasFirstUserMessage(value: string | null): boolean {
261
+ return typeof value === "string" && value.trim().length > 0;
196
262
  }
197
263
 
198
- function samePath(a: string, b: string): boolean {
199
- const left = resolve(a);
200
- const right = resolve(b);
201
- return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
264
+ export interface CodexHistoryVerifiedNoopProof {
265
+ readonly kind: "verified-noop";
266
+ readonly pendingRows: 0;
267
+ readonly backupEntries: 0;
268
+ readonly canonicalStateDbPath: string;
269
+ readonly stateDbPresent: true;
270
+ readonly canonicalBackupPath: string;
271
+ readonly backupPresent: boolean;
202
272
  }
203
273
 
204
- function readBackup(path: string, stateDbPath?: string): BackupManifest {
205
- if (!existsSync(path)) return { version: 1, stateDbPath, entries: {} };
206
- try {
207
- const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<BackupManifest>;
208
- if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") {
209
- return { version: 1, stateDbPath, entries: {} };
274
+ export type CodexHistoryNoopSnapshot =
275
+ | CodexHistoryVerifiedNoopProof
276
+ | {
277
+ readonly kind: "work-pending";
278
+ readonly pendingRows: number;
279
+ readonly backupEntries: number;
280
+ readonly canonicalStateDbPath: string;
281
+ readonly stateDbPresent: boolean;
282
+ readonly canonicalBackupPath: string;
283
+ readonly backupPresent: boolean;
210
284
  }
211
- if (stateDbPath && typeof parsed.stateDbPath === "string" && !samePath(parsed.stateDbPath, stateDbPath)) {
212
- return { version: 1, stateDbPath, entries: {} };
285
+ | {
286
+ readonly kind: "unknown";
287
+ readonly pendingRows: null;
288
+ readonly backupEntries: null;
289
+ readonly canonicalStateDbPath: string;
290
+ readonly stateDbPresent: boolean;
291
+ readonly canonicalBackupPath: string;
292
+ readonly backupPresent: boolean;
293
+ readonly reason: "backup-path" | "database-absent" | "manifest-read" | "manifest-schema" | "manifest-foreign" | "database-query" | "snapshot-race";
294
+ };
295
+
296
+ type StrictBackupInspection =
297
+ | { readonly kind: "known"; readonly present: boolean; readonly entries: number; readonly fingerprint: string }
298
+ | { readonly kind: "unknown"; readonly present: boolean; readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign";
299
+ readonly failureReason?: "busy" | "permission" };
300
+
301
+ type StrictBackupRead =
302
+ | {
303
+ readonly kind: "known";
304
+ readonly present: boolean;
305
+ readonly manifest: CodexHistoryBackupManifest;
306
+ readonly fingerprint: string;
307
+ }
308
+ | {
309
+ readonly kind: "unknown";
310
+ readonly present: true;
311
+ readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign";
312
+ readonly failureReason?: "busy" | "permission";
313
+ };
314
+
315
+ let afterNoopPendingCountForTests: (() => void) | undefined;
316
+ let beforeHistoryBackupConsumeForTests: (() => void) | undefined;
317
+ let beforeStrictHistoryRolloutAppendForTests: (() => void) | undefined;
318
+ let afterStrictHistoryRolloutAppendForTests: (() => void) | undefined;
319
+ let beforeHistoryApplyTransactionForTests: (() => void) | undefined;
320
+
321
+ /** Test seam: runs after the pending count and before stability validation. */
322
+ export function setAfterNoopPendingCountForTests(hook: (() => void) | undefined): void {
323
+ afterNoopPendingCountForTests = hook;
324
+ }
325
+
326
+ /** Test seam: runs after exact DB/rollout readback and before manifest fingerprint CAS. */
327
+ export function setBeforeHistoryBackupConsumeForTests(hook: (() => void) | undefined): void {
328
+ beforeHistoryBackupConsumeForTests = hook;
329
+ }
330
+
331
+ /** Test seam: models a same-file append after strict snapshot validation but before our append. */
332
+ export function setBeforeStrictHistoryRolloutAppendForTests(hook: (() => void) | undefined): void {
333
+ beforeStrictHistoryRolloutAppendForTests = hook;
334
+ }
335
+
336
+ /** Test seam: models a write/finalization failure after the strict append reached disk. */
337
+ export function setAfterStrictHistoryRolloutAppendForTests(hook: (() => void) | undefined): void {
338
+ afterStrictHistoryRolloutAppendForTests = hook;
339
+ }
340
+
341
+ /** Test seam: runs after manifest snapshot publication and before apply's database CAS. */
342
+ export function setBeforeHistoryApplyTransactionForTests(hook: (() => void) | undefined): void {
343
+ beforeHistoryApplyTransactionForTests = hook;
344
+ }
345
+
346
+ function readBackupStrict(path: string, stateDbPath: string): StrictBackupRead {
347
+ let pathStat: ReturnType<typeof lstatSync>;
348
+ try {
349
+ pathStat = lstatSync(path);
350
+ } catch (error) {
351
+ const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
352
+ if (code !== "ENOENT") {
353
+ const failureReason = classifyRecoverableHistoryError(error);
354
+ return {
355
+ kind: "unknown",
356
+ present: true,
357
+ reason: "manifest-read",
358
+ ...(failureReason === "busy" || failureReason === "permission" ? { failureReason } : {}),
359
+ };
213
360
  }
214
- return { version: 1, stateDbPath: parsed.stateDbPath ?? stateDbPath, entries: parsed.entries };
361
+ return {
362
+ kind: "known",
363
+ present: false,
364
+ manifest: { version: 1, stateDbPath, entries: {} },
365
+ fingerprint: "absent",
366
+ };
367
+ }
368
+ if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
369
+ return { kind: "unknown", present: true, reason: "manifest-read" };
370
+ }
371
+ let raw: string;
372
+ try {
373
+ raw = readFileSync(path, "utf8");
374
+ } catch (error) {
375
+ const failureReason = classifyRecoverableHistoryError(error);
376
+ return {
377
+ kind: "unknown",
378
+ present: true,
379
+ reason: "manifest-read",
380
+ ...(failureReason === "busy" || failureReason === "permission" ? { failureReason } : {}),
381
+ };
382
+ }
383
+ let parsed: unknown;
384
+ try {
385
+ parsed = JSON.parse(raw);
215
386
  } catch {
216
- return { version: 1, stateDbPath, entries: {} };
387
+ return { kind: "unknown", present: true, reason: "manifest-read" };
217
388
  }
389
+ const validated = validateCodexHistoryBackupManifest(parsed, stateDbPath);
390
+ if (!validated.ok) {
391
+ return {
392
+ kind: "unknown",
393
+ present: true,
394
+ reason: validated.reason === "foreign-database" ? "manifest-foreign" : "manifest-schema",
395
+ };
396
+ }
397
+ return {
398
+ kind: "known",
399
+ present: true,
400
+ manifest: validated.manifest,
401
+ fingerprint: createHash("sha256").update(raw).digest("hex"),
402
+ };
403
+ }
404
+
405
+ function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupInspection {
406
+ const read = readBackupStrict(path, stateDbPath);
407
+ return read.kind === "unknown"
408
+ ? read
409
+ : {
410
+ kind: "known",
411
+ present: read.present,
412
+ entries: Object.keys(read.manifest.entries).length,
413
+ fingerprint: read.fingerprint,
414
+ };
415
+ }
416
+
417
+ function historyFileIdentity(path: string): string | null {
418
+ try {
419
+ const stat = statSync(path);
420
+ return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+
426
+ function readHistoryDataVersion(db: Database): number | null {
427
+ const row = db.query<{ data_version: number }, []>("PRAGMA data_version").get();
428
+ const value = row?.data_version;
429
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
430
+ }
431
+
432
+ function readBackup(path: string, stateDbPath: string): Extract<StrictBackupRead, { kind: "known" }> {
433
+ const read = readBackupStrict(path, stateDbPath);
434
+ if (read.kind === "unknown") {
435
+ if (read.failureReason) {
436
+ throw Object.assign(
437
+ new Error(read.failureReason === "busy" ? "history backup is busy" : "history backup permission denied"),
438
+ { code: read.failureReason === "busy" ? "EBUSY" : "EACCES" },
439
+ );
440
+ }
441
+ throw new CodexHistoryIntegrityError(`history_backup_${read.reason.replaceAll("-", "_")}`);
442
+ }
443
+ return read;
444
+ }
445
+
446
+ function consumeBackupIfUnchanged(path: string, stateDbPath: string, expectedFingerprint: string): void {
447
+ const current = readBackupStrict(path, stateDbPath);
448
+ if (current.kind !== "known"
449
+ || !current.present
450
+ || current.fingerprint !== expectedFingerprint) {
451
+ throw new CodexHistoryIntegrityError("history_backup_changed_during_restore");
452
+ }
453
+ unlinkSync(path);
218
454
  }
219
455
 
220
- function writeBackup(path: string, manifest: BackupManifest, stateDbPath?: string): void {
456
+ function writeBackup(path: string, manifest: CodexHistoryBackupManifest, stateDbPath?: string): void {
221
457
  if (Object.keys(manifest.entries).length === 0) {
222
458
  if (existsSync(path)) unlinkSync(path);
223
459
  return;
@@ -226,17 +462,181 @@ function writeBackup(path: string, manifest: BackupManifest, stateDbPath?: strin
226
462
  atomicWriteFile(path, JSON.stringify({ ...manifest, stateDbPath: manifest.stateDbPath ?? stateDbPath }, null, 2) + "\n");
227
463
  }
228
464
 
229
- function rememberOriginal(manifest: BackupManifest, row: ThreadRow): void {
465
+ function rememberOriginal(manifest: CodexHistoryBackupManifest, row: ThreadRow): void {
230
466
  if (manifest.entries[row.id]) return;
231
467
  manifest.entries[row.id] = {
232
468
  id: row.id,
233
469
  rolloutPath: row.rollout_path,
234
470
  modelProvider: row.model_provider,
235
471
  source: row.source,
236
- hasUserEvent: Number(row.has_user_event) || 0,
472
+ hasUserEvent: Number(row.has_user_event) === 1 ? 1 : 0,
473
+ };
474
+ }
475
+
476
+ function rowMatchesRestoreTuple(
477
+ row: RestoreRowSnapshot,
478
+ modelProvider: string,
479
+ source: string,
480
+ hasUserEvent: number,
481
+ ): boolean {
482
+ return row.model_provider === modelProvider
483
+ && row.source === source
484
+ && row.has_user_event === hasUserEvent;
485
+ }
486
+
487
+ function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: CodexHistoryBackupEntry): boolean {
488
+ if (entry.modelProvider === "openai") {
489
+ const postHasUserEvent = hasFirstUserMessage(row.first_user_message) ? 1 : entry.hasUserEvent;
490
+ return rowMatchesRestoreTuple(row, "opencodex", entry.source, postHasUserEvent);
491
+ }
492
+ return hasFirstUserMessage(row.first_user_message)
493
+ && (
494
+ rowMatchesRestoreTuple(row, "opencodex", "cli", 1)
495
+ // Older restore code coerced an opencodex/exec original into this exact tuple before
496
+ // consuming its manifest. Accept that one known post-image so an interrupted old restore
497
+ // can use its preserved first-line padding to recover exact provenance.
498
+ || rowMatchesRestoreTuple(row, "openai", "cli", 1)
499
+ );
500
+ }
501
+
502
+ interface RestoreRolloutSnapshot {
503
+ readonly identity: string;
504
+ readonly latestProvider: string;
505
+ readonly latestSource: string;
506
+ }
507
+
508
+ function normalizedSessionMetaTuple(meta: ParsedSessionMeta): { provider: string; source: string } {
509
+ const payload = meta.record.payload;
510
+ return {
511
+ provider: typeof payload.model_provider === "string" && payload.model_provider
512
+ ? payload.model_provider
513
+ : "openai",
514
+ source: typeof payload.source === "string" && payload.source ? payload.source : "cli",
515
+ };
516
+ }
517
+
518
+ function rolloutMatchesRestoreTuple(
519
+ meta: ParsedSessionMeta,
520
+ entry: CodexHistoryBackupEntry,
521
+ provider: string,
522
+ source: string,
523
+ ): boolean {
524
+ const tuple = normalizedSessionMetaTuple(meta);
525
+ return meta.record.payload.id === entry.id
526
+ && tuple.provider === provider
527
+ && tuple.source === source;
528
+ }
529
+
530
+ function rolloutMatchesExpectedPostImage(meta: ParsedSessionMeta, entry: CodexHistoryBackupEntry): boolean {
531
+ if (entry.modelProvider === "openai") {
532
+ const tuple = normalizedSessionMetaTuple(meta);
533
+ const rawSource = meta.record.payload.source;
534
+ return meta.record.payload.id === entry.id
535
+ && tuple.provider === "opencodex"
536
+ // Older/native session_meta records can omit source even when SQLite identifies the
537
+ // surface as vscode. Apply changes only the provider, so absence is a valid post-image;
538
+ // restore appends the exact manifest source before consuming provenance.
539
+ && ((typeof rawSource !== "string" || !rawSource) || tuple.source === entry.source);
540
+ }
541
+ return rolloutMatchesRestoreTuple(meta, entry, "opencodex", "cli")
542
+ // Keep the same one-version recovery bridge as the database tuple check: older forced
543
+ // restore code could already have produced openai/cli before consuming this manifest.
544
+ || rolloutMatchesRestoreTuple(meta, entry, "openai", "cli");
545
+ }
546
+
547
+ function snapshotRolloutForRestore(entry: CodexHistoryBackupEntry): RestoreRolloutSnapshot {
548
+ const identityBefore = historyFileIdentity(entry.rolloutPath);
549
+ if (identityBefore === null) {
550
+ throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable");
551
+ }
552
+ const latest = readLatestSessionMeta(entry.rolloutPath);
553
+ if (!latest
554
+ || (!rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)
555
+ && !rolloutMatchesExpectedPostImage(latest, entry))) {
556
+ throw new CodexHistoryIntegrityError("history_backup_rollout_postimage_mismatch");
557
+ }
558
+ const firstProvider = readFirstLineProviderValue(entry.rolloutPath, entry.id);
559
+ if (firstProvider !== "openai" && firstProvider !== "opencodex") {
560
+ throw new CodexHistoryIntegrityError("history_backup_rollout_postimage_mismatch");
561
+ }
562
+ if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) === "unsafe") {
563
+ throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable");
564
+ }
565
+ if (historyFileIdentity(entry.rolloutPath) !== identityBefore) {
566
+ throw new CodexHistoryIntegrityError("history_backup_rollout_changed_during_restore");
567
+ }
568
+ const tuple = normalizedSessionMetaTuple(latest);
569
+ return {
570
+ identity: identityBefore,
571
+ latestProvider: tuple.provider,
572
+ latestSource: tuple.source,
237
573
  };
238
574
  }
239
575
 
576
+ interface RestoreTargetPreflight {
577
+ readonly snapshots: Map<string, RestoreRowSnapshot>;
578
+ readonly rolloutSnapshots: Map<string, RestoreRolloutSnapshot>;
579
+ }
580
+
581
+ /**
582
+ * Read-only authority shared by restore and status/doctor. Every manifest entry must still
583
+ * identify either its exact target tuple or the one OpenCodex post-image, and its rollout
584
+ * must be present, stable, same-id, and durably restorable before callers call it pending.
585
+ */
586
+ function preflightRestoreTargets(
587
+ getCurrent: (id: string) => RestoreRowSnapshot | null,
588
+ entries: CodexHistoryBackupEntry[],
589
+ ): RestoreTargetPreflight {
590
+ const snapshots = preflightRestoreRows(getCurrent, entries);
591
+ const rolloutSnapshots = new Map<string, RestoreRolloutSnapshot>();
592
+ for (const entry of entries) {
593
+ // Validate every rollout before the first mutation. A later missing, foreign, or
594
+ // unpatchable entry must not leave an earlier file partially restored.
595
+ rolloutSnapshots.set(entry.id, snapshotRolloutForRestore(entry));
596
+ }
597
+ return { snapshots, rolloutSnapshots };
598
+ }
599
+
600
+ /** Cheap manifest-to-database authority check used by recurring no-op probes. */
601
+ function preflightRestoreRows(
602
+ getCurrent: (id: string) => RestoreRowSnapshot | null,
603
+ entries: CodexHistoryBackupEntry[],
604
+ ): Map<string, RestoreRowSnapshot> {
605
+ const snapshots = new Map<string, RestoreRowSnapshot>();
606
+ for (const entry of entries) {
607
+ const row = getCurrent(entry.id);
608
+ if (!row || typeof row.rollout_path !== "string" || !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)) {
609
+ throw new CodexHistoryIntegrityError("history_backup_target_mismatch");
610
+ }
611
+ if (!rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)
612
+ && !rowMatchesExpectedPostImage(row, entry)) {
613
+ throw new CodexHistoryIntegrityError("history_backup_postimage_mismatch");
614
+ }
615
+ snapshots.set(entry.id, row);
616
+ }
617
+ return snapshots;
618
+ }
619
+
620
+ function assertRestoreReadback(
621
+ getCurrent: (id: string) => RestoreRowSnapshot | null,
622
+ entries: CodexHistoryBackupEntry[],
623
+ ): void {
624
+ for (const entry of entries) {
625
+ const row = getCurrent(entry.id);
626
+ if (!row
627
+ || !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)
628
+ || !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)) {
629
+ throw new CodexHistoryIntegrityError("history_backup_database_readback_mismatch");
630
+ }
631
+ const latest = readLatestSessionMeta(entry.rolloutPath);
632
+ if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) !== "current"
633
+ || !latest
634
+ || !rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)) {
635
+ throw new CodexHistoryIntegrityError("history_backup_rollout_readback_mismatch");
636
+ }
637
+ }
638
+ }
639
+
240
640
  interface ParsedSessionMeta {
241
641
  record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record<string, unknown> };
242
642
  }
@@ -262,6 +662,10 @@ function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
262
662
  */
263
663
  export function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
264
664
  const raw = readFileSync(path, "utf8");
665
+ return readLatestSessionMetaFromText(raw);
666
+ }
667
+
668
+ function readLatestSessionMetaFromText(raw: string): ParsedSessionMeta | null {
265
669
  const lines = raw.split("\n");
266
670
  for (let i = lines.length - 1; i >= 0; i--) {
267
671
  const line = lines[i];
@@ -273,6 +677,39 @@ export function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
273
677
  return null;
274
678
  }
275
679
 
680
+ function readLatestSessionMetaForIdFromText(raw: string, expectedId: string): ParsedSessionMeta | null {
681
+ const lines = raw.split("\n");
682
+ for (let i = lines.length - 1; i >= 0; i--) {
683
+ const line = lines[i];
684
+ if (!line || !line.includes("\"session_meta\"")) continue;
685
+ const meta = parseSessionMetaLine(line);
686
+ if (meta?.record.payload.id === expectedId) return meta;
687
+ }
688
+ return null;
689
+ }
690
+
691
+ function compensateConcurrentSessionMetaAppend(
692
+ path: string,
693
+ expectedId: string,
694
+ appended: Buffer,
695
+ minimumOffset: number,
696
+ ): void {
697
+ try {
698
+ const raw = readFileSync(path);
699
+ const ownOffset = raw.lastIndexOf(appended);
700
+ if (ownOffset < minimumOffset) return;
701
+ const withoutOwnAppend = Buffer.concat([
702
+ raw.subarray(0, ownOffset),
703
+ raw.subarray(ownOffset + appended.length),
704
+ ]).toString("utf8");
705
+ const prior = readLatestSessionMetaForIdFromText(withoutOwnAppend, expectedId);
706
+ if (prior) appendRolloutLine(path, JSON.stringify(prior.record));
707
+ } catch {
708
+ // The caller reports an integrity conflict and retains the manifest. Compensation is
709
+ // best-effort because a second write failure must not erase the original failure evidence.
710
+ }
711
+ }
712
+
276
713
  /**
277
714
  * Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a
278
715
  * Phase-2 quarantine predates full `satellite-backup.json` thread snapshots.
@@ -412,15 +849,34 @@ function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | nu
412
849
  /**
413
850
  * Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line,
414
851
  * rather than rewriting line 1. The appended line clones the latest metadata payload (so no field
415
- * is accidentally reset to empty) and applies only the requested changes. Returns false when the
416
- * rollout is missing, has no parseable `session_meta`, its latest `session_meta` belongs to a
417
- * different thread id, or it already matches the desired values.
852
+ * is accidentally reset to empty) and applies only the requested changes. `durableProvider`
853
+ * reports whether line 1 already carried (or was safely patched to) the requested provider.
418
854
  */
419
- function updateSessionMeta(path: string, expectedId: string, patch: { provider?: string; source?: string }): boolean {
420
- if (!path || !existsSync(path)) return false;
855
+ interface SessionMetaUpdateResult {
856
+ changed: boolean;
857
+ durableProvider: boolean;
858
+ conflict?: true;
859
+ }
860
+
861
+ function updateSessionMeta(
862
+ path: string,
863
+ expectedId: string,
864
+ patch: { provider?: string; source?: string },
865
+ options: {
866
+ requireDurableProvider?: boolean;
867
+ expectedFileIdentity?: string;
868
+ expectedLatestProvider?: string;
869
+ expectedLatestSource?: string;
870
+ } = {},
871
+ ): SessionMetaUpdateResult {
872
+ if (!path || !existsSync(path)) return { changed: false, durableProvider: false };
873
+ if (options.expectedFileIdentity !== undefined
874
+ && historyFileIdentity(path) !== options.expectedFileIdentity) {
875
+ return { changed: false, durableProvider: false, conflict: true };
876
+ }
421
877
 
422
878
  const latest = readLatestSessionMeta(path);
423
- if (!latest) return false;
879
+ if (!latest) return { changed: false, durableProvider: false };
424
880
  const record = latest.record;
425
881
 
426
882
  // The app ignores `session_meta` lines whose payload id != the canonical thread id
@@ -428,8 +884,23 @@ function updateSessionMeta(path: string, expectedId: string, patch: { provider?:
428
884
  // metadata, so an id-mismatched latest line means we'd be cloning the wrong thread's meta and
429
885
  // appending a line the app would discard. Skip rather than write a no-op/misleading line.
430
886
  const payloadId = record.payload.id;
431
- if (typeof payloadId !== "string" || payloadId !== expectedId) return false;
887
+ if (typeof payloadId !== "string" || payloadId !== expectedId) {
888
+ return { changed: false, durableProvider: false };
889
+ }
890
+ const latestProvider = typeof record.payload.model_provider === "string" && record.payload.model_provider
891
+ ? record.payload.model_provider
892
+ : "openai";
893
+ const latestSource = typeof record.payload.source === "string" && record.payload.source
894
+ ? record.payload.source
895
+ : "cli";
896
+ if ((options.expectedLatestProvider !== undefined && latestProvider !== options.expectedLatestProvider)
897
+ || (options.expectedLatestSource !== undefined && latestSource !== options.expectedLatestSource)
898
+ || (options.expectedFileIdentity !== undefined
899
+ && historyFileIdentity(path) !== options.expectedFileIdentity)) {
900
+ return { changed: false, durableProvider: false, conflict: true };
901
+ }
432
902
 
903
+ const previousRecord = JSON.stringify(record);
433
904
  let changed = false;
434
905
  if (patch.provider !== undefined && record.payload.model_provider !== patch.provider) {
435
906
  record.payload.model_provider = patch.provider;
@@ -439,40 +910,85 @@ function updateSessionMeta(path: string, expectedId: string, patch: { provider?:
439
910
  record.payload.source = patch.source;
440
911
  changed = true;
441
912
  }
442
- if (!changed) return false;
913
+ const strictRestore = options.expectedFileIdentity !== undefined;
914
+ if (strictRestore) {
915
+ if (historyFileIdentity(path) !== options.expectedFileIdentity) {
916
+ return { changed: false, durableProvider: false, conflict: true };
917
+ }
918
+
919
+ let appended: Buffer | null = null;
920
+ let beforeSize = 0;
921
+ if (changed) {
922
+ beforeSize = statSync(path).size;
923
+ beforeStrictHistoryRolloutAppendForTests?.();
924
+ record.timestamp = new Date().toISOString();
925
+ appended = appendRolloutLine(path, JSON.stringify(record));
926
+ afterStrictHistoryRolloutAppendForTests?.();
927
+ let cleanAppend = false;
928
+ try {
929
+ const raw = readFileSync(path);
930
+ cleanAppend = raw.length === beforeSize + appended.length
931
+ && raw.subarray(beforeSize).equals(appended);
932
+ } catch {
933
+ cleanAppend = false;
934
+ }
935
+ if (!cleanAppend) {
936
+ compensateConcurrentSessionMetaAppend(path, expectedId, appended, beforeSize);
937
+ return { changed: true, durableProvider: false, conflict: true };
938
+ }
939
+ }
940
+
941
+ let firstLine: FirstLineProviderResult = "current";
942
+ if (patch.provider !== undefined) {
943
+ try {
944
+ firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider);
945
+ } catch {
946
+ firstLine = "unsafe";
947
+ }
948
+ }
949
+ if (options.requireDurableProvider && firstLine === "unsafe") {
950
+ // Restore the pre-operation last-writer-wins tuple after an append succeeded but the
951
+ // first-line durability repair failed. The extra lines remain audit evidence; the manifest
952
+ // remains authoritative and the retry cannot mistake this for convergence.
953
+ if (appended) appendRolloutLine(path, previousRecord);
954
+ return {
955
+ changed: changed || historyFileIdentity(path) !== options.expectedFileIdentity,
956
+ durableProvider: false,
957
+ };
958
+ }
959
+ return {
960
+ changed: changed || firstLine === "patched",
961
+ durableProvider: firstLine !== "unsafe",
962
+ };
963
+ }
443
964
 
444
965
  // Cover Codex's *other* provider reader: `read_session_meta_line` reads only line 1, and the
445
966
  // app clones it when writing later git/memory-mode metadata. Appending alone leaves a stale
446
- // line-1 provider that the clone would re-append, so for a length-preserving provider change we
447
- // also patch line 1 in place (no inode swap, no truncate). Best-effort: when it can't be done
448
- // safely (e.g. a length-growing change), the trailing append below is still correct for the
449
- // SQLite replay path.
967
+ // line-1 provider that the clone would re-append. Exact restore requires this repair before it
968
+ // may update SQLite or consume the only provenance manifest; forward routing remains best-effort.
969
+ let firstLine: FirstLineProviderResult = "current";
450
970
  if (patch.provider !== undefined) {
451
- try { patchFirstLineProviderInPlace(path, expectedId, patch.provider); } catch { /* best-effort line-1 patch */ }
971
+ try {
972
+ firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider);
973
+ } catch {
974
+ firstLine = "unsafe";
975
+ }
976
+ if (options.requireDurableProvider && firstLine === "unsafe") {
977
+ return { changed: false, durableProvider: false };
978
+ }
452
979
  }
453
980
 
454
- // Refresh the line timestamp so the appended record reads as the newest metadata.
981
+ const firstLineChanged = firstLine === "patched";
982
+ if (!changed) return { changed: firstLineChanged, durableProvider: firstLine !== "unsafe" };
983
+
984
+ // Forward/legacy mode remains best-effort. Strict manifest restore uses the CAS-style append
985
+ // branch above so a concurrent same-id provider decision cannot be overwritten.
455
986
  record.timestamp = new Date().toISOString();
456
987
  appendRolloutLine(path, JSON.stringify(record));
457
- return true;
988
+ return { changed: true, durableProvider: firstLine !== "unsafe" };
458
989
  }
459
990
 
460
- function toNativeRestoreTarget(entry: BackupEntry): NativeRestoreTarget {
461
- if (entry.modelProvider !== "opencodex") {
462
- return {
463
- modelProvider: entry.modelProvider,
464
- source: entry.source,
465
- hasUserEvent: entry.hasUserEvent,
466
- };
467
- }
468
- return {
469
- modelProvider: "openai",
470
- source: entry.source === "exec" ? "cli" : entry.source,
471
- hasUserEvent: 1,
472
- };
473
- }
474
-
475
- function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: number } {
991
+ function relabelAllRoutedHistoryToOpenai(db: Database): { rows: number; files: number } {
476
992
  const rows = db
477
993
  .query<ThreadRow, []>(`
478
994
  SELECT id, rollout_path, model_provider, source, has_user_event
@@ -488,9 +1004,9 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu
488
1004
  if (updateSessionMeta(row.rollout_path, row.id, {
489
1005
  provider: "openai",
490
1006
  source: row.source === "exec" ? "cli" : undefined,
491
- })) files++;
1007
+ }).changed) files++;
492
1008
  } catch {
493
- /* native restore should continue even if an old rollout is missing */
1009
+ /* explicit legacy recovery still relabels the DB when an old rollout is missing */
494
1010
  }
495
1011
  }
496
1012
 
@@ -508,19 +1024,24 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu
508
1024
  return { rows: rows.length, files };
509
1025
  }
510
1026
 
511
- export function isRecoverableHistoryError(error: unknown): boolean {
1027
+ export function classifyRecoverableHistoryError(error: unknown): CodexHistoryFailureReason | null {
512
1028
  const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
513
1029
  const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
514
- return code === "SQLITE_BUSY"
1030
+ if (code === "SQLITE_BUSY"
515
1031
  || code === "SQLITE_LOCKED"
516
1032
  || code === "EBUSY"
517
- || code === "EPERM"
518
- || code === "EACCES"
519
1033
  || message.includes("database is locked")
520
1034
  || message.includes("database is busy")
521
- || message.includes("resource busy")
1035
+ || message.includes("resource busy")) return "busy";
1036
+ if (code === "EPERM"
1037
+ || code === "EACCES"
522
1038
  || message.includes("operation not permitted")
523
- || message.includes("permission denied");
1039
+ || message.includes("permission denied")) return "permission";
1040
+ return null;
1041
+ }
1042
+
1043
+ export function isRecoverableHistoryError(error: unknown): boolean {
1044
+ return classifyRecoverableHistoryError(error) !== null;
524
1045
  }
525
1046
 
526
1047
  const HISTORY_RETRY_DELAY_MS = 500;
@@ -533,24 +1054,34 @@ const HISTORY_RETRY_ATTEMPTS = 2;
533
1054
  * error — callers surface that as `failed: true` instead of a silent no-op. Hard errors
534
1055
  * (corruption, programming bugs) still throw.
535
1056
  */
536
- export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null {
1057
+ function withHistoryRetryResult<T>(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}):
1058
+ | { ok: true; value: T }
1059
+ | { ok: false; reason: CodexHistoryFailureReason } {
537
1060
  const sleepFn = io.sleepFn ?? Bun.sleepSync;
538
1061
  const attempts = Math.max(1, io.attempts ?? HISTORY_RETRY_ATTEMPTS);
539
1062
  const delayMs = io.delayMs ?? HISTORY_RETRY_DELAY_MS;
540
1063
  for (let attempt = 0; ; attempt++) {
541
1064
  try {
542
- return fn();
1065
+ return { ok: true, value: fn() };
543
1066
  } catch (error) {
544
- if (!isRecoverableHistoryError(error)) throw error;
545
- if (attempt >= attempts - 1) return null;
1067
+ const reason = classifyRecoverableHistoryError(error);
1068
+ if (!reason) throw error;
1069
+ if (attempt >= attempts - 1) return { ok: false, reason };
546
1070
  try { sleepFn(delayMs); } catch { /* sleep is best-effort */ }
547
1071
  }
548
1072
  }
549
1073
  }
550
1074
 
1075
+ export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null {
1076
+ const result = withHistoryRetryResult(fn, io);
1077
+ return result.ok ? result.value : null;
1078
+ }
1079
+
551
1080
  /**
552
- * True when a READONLY probe proves the openai-direction restore would be a no-op:
553
- * zero threads still tagged opencodex AND an empty backup manifest. Used to skip the
1081
+ * True when a READONLY probe proves the native-direction restore would be a no-op:
1082
+ * the history database is readable and the backup manifest has no restore entries. Bare
1083
+ * opencodex-tagged rows are not actionable: without a manifest their original provider is
1084
+ * unknown, so only the explicit legacy recovery command may relabel them. Used to skip the
554
1085
  * write-open entirely in the Design B steady state — on Windows the Codex app holds
555
1086
  * `state_5.sqlite` (WAL, busy_timeout 5s), so an unnecessary write open can stall for
556
1087
  * seconds and surface a false lock warning, while WAL always admits readers. A failed
@@ -558,14 +1089,16 @@ export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) =>
558
1089
  * to the write attempt and keep today's behavior for genuinely unknown state.
559
1090
  */
560
1091
  function openaiRestoreIsNoop(stateDbPath: string, backupPath: string): boolean {
561
- const pending = countPendingOpencodexHistory(stateDbPath, backupPath);
1092
+ const pending = countPendingOpencodexHistory(stateDbPath, backupPath, {
1093
+ validateRestoreTargets: false,
1094
+ });
562
1095
  return !pending.failed && pending.pendingRows === 0 && pending.backupEntries === 0;
563
1096
  }
564
1097
 
565
1098
  export function syncCodexHistoryProvider(
566
1099
  provider: CodexHistoryProvider,
567
- stateDbPath = STATE_DB_PATH,
568
- backupPath = HISTORY_BACKUP_PATH,
1100
+ stateDbPath = resolveCodexStateDbPath(),
1101
+ backupPath = historyBackupPathFor(stateDbPath),
569
1102
  opts: { skipWhenProvablyNoop?: boolean } = {},
570
1103
  ): CodexHistorySyncResult {
571
1104
  // Opt-in steady-state gate (Design B loopback callers only): default semantics of
@@ -574,28 +1107,41 @@ export function syncCodexHistoryProvider(
574
1107
  && openaiRestoreIsNoop(stateDbPath, backupPath)) {
575
1108
  return { rows: 0, files: 0 };
576
1109
  }
577
- return withHistoryRetry(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath))
578
- ?? { rows: 0, files: 0, failed: true };
1110
+ try {
1111
+ const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath));
1112
+ return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
1113
+ } catch (error) {
1114
+ if (error instanceof CodexHistoryIntegrityError) {
1115
+ return integrityFailureResult(error);
1116
+ }
1117
+ throw error;
1118
+ }
579
1119
  }
580
1120
 
581
1121
  function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult {
582
- if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
1122
+ if (!existsSync(stateDbPath)) {
1123
+ const backup = readBackup(backupPath, stateDbPath);
1124
+ if (provider === "openai" && Object.keys(backup.manifest.entries).length > 0) {
1125
+ throw new CodexHistoryIntegrityError("history_state_database_missing");
1126
+ }
1127
+ return { rows: 0, files: 0 };
1128
+ }
583
1129
  if (provider === "openai") return restoreCodexHistoryProvider(stateDbPath, backupPath);
584
1130
 
585
1131
  const db = openStateDb(stateDbPath);
586
1132
  try {
587
- const placeholders = RESUMABLE_SOURCES.map(() => "?").join(",");
1133
+ const placeholders = CODEX_HISTORY_RESUMABLE_SOURCES.map(() => "?").join(",");
588
1134
  const openaiRows = db
589
- .query<ThreadRow, string[]>(`
590
- SELECT id, rollout_path, model_provider, source, has_user_event
1135
+ .query<ApplyRowSnapshot, string[]>(`
1136
+ SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message
591
1137
  FROM threads
592
1138
  WHERE model_provider = 'openai'
593
1139
  AND source IN (${placeholders})
594
1140
  `)
595
- .all(...RESUMABLE_SOURCES);
1141
+ .all(...CODEX_HISTORY_RESUMABLE_SOURCES);
596
1142
  const execRows = db
597
- .query<ThreadRow, []>(`
598
- SELECT id, rollout_path, model_provider, source, has_user_event
1143
+ .query<ApplyRowSnapshot, []>(`
1144
+ SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message
599
1145
  FROM threads
600
1146
  WHERE model_provider = 'opencodex'
601
1147
  AND source = 'exec'
@@ -603,49 +1149,92 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
603
1149
  `)
604
1150
  .all();
605
1151
 
606
- const manifest = readBackup(backupPath, stateDbPath);
1152
+ const manifest = readBackup(backupPath, stateDbPath).manifest;
607
1153
  for (const row of [...openaiRows, ...execRows]) rememberOriginal(manifest, row);
608
1154
  writeBackup(backupPath, manifest, stateDbPath);
609
1155
 
610
1156
  let files = 0;
611
- for (const row of openaiRows) {
612
- try {
613
- if (updateSessionMeta(row.rollout_path, row.id, { provider: "opencodex" })) files++;
614
- } catch {
615
- /* best-effort; keep DB migration moving even if one old rollout is malformed */
616
- }
617
- }
618
- for (const row of execRows) {
619
- try {
620
- if (updateSessionMeta(row.rollout_path, row.id, { source: "cli" })) files++;
621
- } catch {
622
- /* best-effort; keep DB migration moving even if one old rollout is malformed */
623
- }
624
- }
625
-
626
1157
  const update = db.transaction(() => {
627
- const markUserEvent = db.query(`
1158
+ const routeOpenai = db.query(`
628
1159
  UPDATE threads
629
- SET has_user_event = 1
1160
+ SET model_provider = 'opencodex',
1161
+ has_user_event = ?
630
1162
  WHERE id = ?
631
- AND trim(coalesce(first_user_message, '')) != ''
1163
+ AND rollout_path = ?
1164
+ AND model_provider = ?
1165
+ AND source = ?
1166
+ AND has_user_event = ?
1167
+ AND first_user_message IS ?
632
1168
  `);
633
- for (const row of [...openaiRows, ...execRows]) markUserEvent.run(row.id);
634
- db.query(`
1169
+ const routeExec = db.query(`
635
1170
  UPDATE threads
636
- SET model_provider = 'opencodex'
637
- WHERE model_provider = 'openai'
638
- AND source IN (${placeholders})
639
- `).run(...RESUMABLE_SOURCES);
640
- db.query(`
641
- UPDATE threads
642
- SET source = 'cli'
643
- WHERE model_provider = 'opencodex'
644
- AND source = 'exec'
1171
+ SET source = 'cli',
1172
+ has_user_event = 1
1173
+ WHERE id = ?
1174
+ AND rollout_path = ?
1175
+ AND model_provider = ?
1176
+ AND source = ?
1177
+ AND has_user_event = ?
1178
+ AND first_user_message IS ?
645
1179
  AND trim(coalesce(first_user_message, '')) != ''
646
- `).run();
1180
+ `);
1181
+ // CAS only the rows that were recorded in this manifest. A thread inserted after the
1182
+ // snapshot must stay native rather than becoming an untracked bare routed row.
1183
+ for (const row of openaiRows) {
1184
+ const targetEvent = hasFirstUserMessage(row.first_user_message) ? 1 : row.has_user_event;
1185
+ const result = routeOpenai.run(
1186
+ targetEvent,
1187
+ row.id,
1188
+ row.rollout_path,
1189
+ row.model_provider,
1190
+ row.source,
1191
+ row.has_user_event,
1192
+ row.first_user_message,
1193
+ );
1194
+ if (result.changes !== 1) {
1195
+ throw new CodexHistoryIntegrityError("history_apply_database_changed_during_route");
1196
+ }
1197
+ }
1198
+ for (const row of execRows) {
1199
+ const result = routeExec.run(
1200
+ row.id,
1201
+ row.rollout_path,
1202
+ row.model_provider,
1203
+ row.source,
1204
+ row.has_user_event,
1205
+ row.first_user_message,
1206
+ );
1207
+ if (result.changes !== 1) {
1208
+ throw new CodexHistoryIntegrityError("history_apply_database_changed_during_route");
1209
+ }
1210
+ }
1211
+
1212
+ // File metadata remains best-effort, but only after every database CAS matched. Thus a
1213
+ // stale snapshot or a newly inserted row cannot be routed before its provenance exists.
1214
+ for (const row of openaiRows) {
1215
+ try {
1216
+ if (updateSessionMeta(row.rollout_path, row.id, { provider: "opencodex" }).changed) files++;
1217
+ } catch {
1218
+ /* keep DB migration moving; the manifest still carries exact original metadata */
1219
+ }
1220
+ }
1221
+ for (const row of execRows) {
1222
+ try {
1223
+ if (updateSessionMeta(row.rollout_path, row.id, { source: "cli" }).changed) files++;
1224
+ } catch {
1225
+ /* keep DB migration moving; the manifest still carries exact original metadata */
1226
+ }
1227
+ }
647
1228
  });
648
- update();
1229
+ try {
1230
+ beforeHistoryApplyTransactionForTests?.();
1231
+ update();
1232
+ } catch (error) {
1233
+ if (files > 0) {
1234
+ throw new CodexHistoryIntegrityError("history_apply_partial_route", { rows: 0, files });
1235
+ }
1236
+ throw error;
1237
+ }
649
1238
 
650
1239
  return { rows: openaiRows.length + execRows.length, files };
651
1240
  } finally {
@@ -654,26 +1243,24 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
654
1243
  }
655
1244
 
656
1245
  function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): CodexHistorySyncResult {
657
- const manifest = readBackup(backupPath, stateDbPath);
1246
+ const backup = readBackup(backupPath, stateDbPath);
1247
+ const manifest = backup.manifest;
658
1248
  const entries = Object.values(manifest.entries);
659
1249
 
660
1250
  const db = openStateDb(stateDbPath);
661
1251
  try {
662
- if (entries.length === 0) {
663
- const ejected = ejectRemainingOpencodexHistory(db);
664
- return ejected.rows > 0 ? { rows: 0, files: ejected.files, ejectedRows: ejected.rows } : { rows: 0, files: 0 };
665
- }
1252
+ if (entries.length === 0) return { rows: 0, files: 0 };
666
1253
 
667
- let files = 0;
668
- for (const entry of entries) {
669
- const target = toNativeRestoreTarget(entry);
670
- try {
671
- if (updateSessionMeta(entry.rolloutPath, entry.id, { provider: target.modelProvider, source: target.source })) files++;
672
- } catch {
673
- /* best-effort; keep DB restore moving even if one rollout disappeared */
674
- }
675
- }
1254
+ // Validate the whole manifest-to-database target set before touching a rollout. Only the
1255
+ // OpenCodex post-image (or an already-restored target from an interrupted retry) is owned by
1256
+ // this manifest. Any other tuple is a newer/foreign provider decision and must win.
1257
+ const current = db.query<RestoreRowSnapshot, [string]>(`
1258
+ SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message
1259
+ FROM threads WHERE id = ?
1260
+ `);
1261
+ const { snapshots, rolloutSnapshots } = preflightRestoreTargets(id => current.get(id), entries);
676
1262
 
1263
+ let files = 0;
677
1264
  const restore = db.transaction(() => {
678
1265
  const update = db.query(`
679
1266
  UPDATE threads
@@ -681,96 +1268,290 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
681
1268
  source = ?,
682
1269
  has_user_event = ?
683
1270
  WHERE id = ?
1271
+ AND rollout_path = ?
1272
+ AND model_provider = ?
1273
+ AND source = ?
1274
+ AND has_user_event = ?
1275
+ AND first_user_message IS ?
684
1276
  `);
685
1277
  for (const entry of entries) {
686
- const target = toNativeRestoreTarget(entry);
687
- update.run(target.modelProvider, target.source, target.hasUserEvent, entry.id);
1278
+ const before = snapshots.get(entry.id);
1279
+ if (!before) throw new CodexHistoryIntegrityError("history_backup_snapshot_missing");
1280
+ const result = update.run(
1281
+ entry.modelProvider,
1282
+ entry.source,
1283
+ entry.hasUserEvent,
1284
+ entry.id,
1285
+ before.rollout_path,
1286
+ before.model_provider,
1287
+ before.source,
1288
+ before.has_user_event,
1289
+ before.first_user_message,
1290
+ );
1291
+ if (result.changes !== 1) {
1292
+ throw new CodexHistoryIntegrityError("history_backup_database_changed_during_restore");
1293
+ }
1294
+ }
1295
+ // Only after every database CAS matched may a rollout move. Keeping the SQLite
1296
+ // transaction open means a file-side refusal rolls the database back, while the manifest
1297
+ // remains the durable retry journal for an exceptional I/O failure.
1298
+ for (const entry of entries) {
1299
+ const before = rolloutSnapshots.get(entry.id);
1300
+ if (!before) throw new CodexHistoryIntegrityError("history_backup_rollout_snapshot_missing");
1301
+ let updated: SessionMetaUpdateResult;
1302
+ try {
1303
+ updated = updateSessionMeta(
1304
+ entry.rolloutPath,
1305
+ entry.id,
1306
+ { provider: entry.modelProvider, source: entry.source },
1307
+ {
1308
+ requireDurableProvider: true,
1309
+ expectedFileIdentity: before.identity,
1310
+ expectedLatestProvider: before.latestProvider,
1311
+ expectedLatestSource: before.latestSource,
1312
+ },
1313
+ );
1314
+ } catch (error) {
1315
+ if (historyFileIdentity(entry.rolloutPath) !== before.identity) files++;
1316
+ throw error;
1317
+ }
1318
+ if (updated.changed) files++;
1319
+ if (updated.conflict) {
1320
+ throw new CodexHistoryIntegrityError("history_backup_rollout_changed_during_restore");
1321
+ }
1322
+ if (!updated.durableProvider) {
1323
+ throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable");
1324
+ }
688
1325
  }
689
1326
  });
690
- restore();
691
- writeBackup(backupPath, { version: 1, stateDbPath, entries: {} }, stateDbPath);
692
- const ejected = ejectRemainingOpencodexHistory(db);
693
- return ejected.rows > 0
694
- ? { rows: entries.length, files: files + ejected.files, ejectedRows: ejected.rows }
695
- : { rows: entries.length, files };
1327
+ try {
1328
+ restore();
1329
+ } catch (error) {
1330
+ if (files > 0) {
1331
+ throw new CodexHistoryIntegrityError("history_backup_partial_restore", { rows: 0, files });
1332
+ }
1333
+ throw error;
1334
+ }
1335
+
1336
+ try {
1337
+ const getCurrent = (id: string) => current.get(id);
1338
+ assertRestoreReadback(getCurrent, entries);
1339
+ beforeHistoryBackupConsumeForTests?.();
1340
+ // The hook models the exact last-moment race: neither a newer database decision nor a
1341
+ // same-id foreign session_meta may be hidden by deleting the only provenance manifest.
1342
+ assertRestoreReadback(getCurrent, entries);
1343
+ consumeBackupIfUnchanged(backupPath, stateDbPath, backup.fingerprint);
1344
+ } catch (error) {
1345
+ if (error instanceof CodexHistoryIntegrityError) {
1346
+ throw new CodexHistoryIntegrityError(error.message, { rows: entries.length, files });
1347
+ }
1348
+ const failureReason = classifyRecoverableHistoryError(error);
1349
+ if (failureReason) {
1350
+ return {
1351
+ rows: entries.length,
1352
+ files,
1353
+ failed: true,
1354
+ failureReason,
1355
+ };
1356
+ }
1357
+ // Once exact targets were written, an unclassified finalization failure is an
1358
+ // applied-but-not-converged integrity state. Preserve that progress instead of
1359
+ // reporting a zero-change failure that invites an unsafe blind retry.
1360
+ throw new CodexHistoryIntegrityError("history_backup_finalization_failed", {
1361
+ rows: entries.length,
1362
+ files,
1363
+ });
1364
+ }
1365
+ return { rows: entries.length, files };
696
1366
  } finally {
697
1367
  db.close();
698
1368
  }
699
1369
  }
700
1370
 
701
- export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number; failed?: true } {
1371
+ export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath()): CodexHistorySyncResult {
702
1372
  if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
703
- return withHistoryRetry(() => {
1373
+ const retried = withHistoryRetryResult(() => {
704
1374
  const db = openStateDb(stateDbPath);
705
1375
  try {
706
- return ejectRemainingOpencodexHistory(db);
1376
+ return relabelAllRoutedHistoryToOpenai(db);
707
1377
  } finally {
708
1378
  db.close();
709
1379
  }
710
- }) ?? { rows: 0, files: 0, failed: true };
1380
+ });
1381
+ return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
711
1382
  }
712
1383
 
713
1384
  /**
714
- * One-time Design-B migration: restore backed-up originals, then eject any remaining
715
- * opencodex-tagged threads to openai. Thin wrapper over the restore path with a
1385
+ * One-time Design-B migration: restore only manifest-backed originals. Untracked
1386
+ * opencodex-tagged threads have unknown provider provenance and remain routed unless the
1387
+ * user explicitly invokes legacy OpenAI recovery. Thin wrapper over the restore path with a
716
1388
  * configurable retry budget — the daemon migration guardian uses `{ attempts: 1 }`
717
1389
  * per tick so a locked DB never stalls the event loop beyond one sqlite busy wait.
718
1390
  */
719
1391
  export function migrateHistoryToOpenai(
720
- stateDbPath = STATE_DB_PATH,
721
- backupPath = HISTORY_BACKUP_PATH,
1392
+ stateDbPath = resolveCodexStateDbPath(),
1393
+ backupPath = historyBackupPathFor(stateDbPath),
722
1394
  opts: { attempts?: number; delayMs?: number; sleepFn?: (ms: number) => void } = {},
723
1395
  ): CodexHistorySyncResult {
724
- if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
725
1396
  // Steady-state gate: this migration is Design-B-specific (inject + guardian callers),
726
1397
  // and after the one-time migration every start would otherwise write-open the DB for
727
1398
  // nothing. A missing DB with a leftover backup manifest does NOT satisfy the gate
728
1399
  // (backupEntries > 0), so the guardian's fresh-reinstall re-count protection holds.
729
1400
  if (openaiRestoreIsNoop(stateDbPath, backupPath)) return { rows: 0, files: 0 };
730
- return withHistoryRetry(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts)
731
- ?? { rows: 0, files: 0, failed: true };
1401
+ try {
1402
+ const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts);
1403
+ return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
1404
+ } catch (error) {
1405
+ if (error instanceof CodexHistoryIntegrityError) {
1406
+ return integrityFailureResult(error);
1407
+ }
1408
+ throw error;
1409
+ }
1410
+ }
1411
+
1412
+ /**
1413
+ * Captures no-op evidence while the caller holds the history serialization
1414
+ * lock H. This function does not acquire H itself. Unknown or foreign backup
1415
+ * state is never collapsed into an empty manifest.
1416
+ */
1417
+ export function snapshotCodexHistoryNoop(
1418
+ stateDbPath: string,
1419
+ backupPath: string,
1420
+ ): CodexHistoryNoopSnapshot {
1421
+ const canonicalStateDbPath = resolve(stateDbPath);
1422
+ const canonicalBackupPath = resolve(backupPath);
1423
+ const stateDbPresent = existsSync(stateDbPath);
1424
+ const backupPresent = existsSync(backupPath);
1425
+ const base = { canonicalStateDbPath, stateDbPresent, canonicalBackupPath, backupPresent };
1426
+ if (!sameCodexHistoryPath(backupPath, historyBackupPathFor(stateDbPath))) {
1427
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "backup-path" };
1428
+ }
1429
+ const backup = inspectBackupForNoop(backupPath, stateDbPath);
1430
+ if (backup.kind === "unknown") {
1431
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: backup.reason };
1432
+ }
1433
+ if (!stateDbPresent) {
1434
+ return backup.entries > 0
1435
+ ? { kind: "work-pending", pendingRows: 0, backupEntries: backup.entries, ...base }
1436
+ : { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-absent" };
1437
+ }
1438
+ const stateDbIdentity = historyFileIdentity(stateDbPath);
1439
+ if (stateDbIdentity === null) {
1440
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "snapshot-race" };
1441
+ }
1442
+ let monitor: Database | undefined;
1443
+ try {
1444
+ monitor = new Database(stateDbPath, { readonly: true });
1445
+ monitor.exec("PRAGMA busy_timeout = 100");
1446
+ const dataVersionBefore = readHistoryDataVersion(monitor);
1447
+ if (dataVersionBefore === null) {
1448
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
1449
+ }
1450
+ const pending = countPendingOpencodexHistory(stateDbPath, backupPath, {
1451
+ validateRestoreTargets: false,
1452
+ });
1453
+ if (pending.failed) {
1454
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
1455
+ }
1456
+ afterNoopPendingCountForTests?.();
1457
+ const backupAfter = inspectBackupForNoop(backupPath, stateDbPath);
1458
+ if (backupAfter.kind === "unknown") {
1459
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: backupAfter.reason };
1460
+ }
1461
+ const dataVersionAfter = readHistoryDataVersion(monitor);
1462
+ if (dataVersionAfter === null) {
1463
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
1464
+ }
1465
+ if (dataVersionAfter !== dataVersionBefore
1466
+ || pending.backupEntries !== backup.entries
1467
+ || backupAfter.entries !== backup.entries
1468
+ || backupAfter.present !== backup.present
1469
+ || backupAfter.fingerprint !== backup.fingerprint
1470
+ || historyFileIdentity(stateDbPath) !== stateDbIdentity
1471
+ || existsSync(stateDbPath) !== stateDbPresent
1472
+ || existsSync(backupPath) !== backupPresent) {
1473
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "snapshot-race" };
1474
+ }
1475
+ return pending.pendingRows === 0 && backup.entries === 0
1476
+ ? { kind: "verified-noop", pendingRows: 0, backupEntries: 0, ...base, stateDbPresent: true }
1477
+ : { kind: "work-pending", pendingRows: pending.pendingRows, backupEntries: backup.entries, ...base };
1478
+ } catch {
1479
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
1480
+ } finally {
1481
+ try {
1482
+ monitor?.close();
1483
+ } catch {
1484
+ // Read-only monitor cleanup cannot make an uncertain snapshot authoritative.
1485
+ }
1486
+ }
732
1487
  }
733
1488
 
734
1489
  export interface PendingHistoryCount {
735
- /** Threads still tagged opencodex that the eject path WOULD move (mirrors its WHERE). */
1490
+ /** Compatibility field; bare routed rows are never automatic restore work. */
736
1491
  pendingRows: number;
737
1492
  /** Entries still recorded in the backup manifest (restore targets). */
738
1493
  backupEntries: number;
739
- /** Set when the DB could not be opened/read (locked); counts are then unknown, not zero. */
1494
+ /** Set when the DB/manifest could not be read or their bound identity is invalid. */
740
1495
  failed?: true;
1496
+ /** Distinguishes retryable contention/access from a manifest or target that needs review. */
1497
+ failureReason?: CodexHistoryFailureReason;
741
1498
  }
742
1499
 
743
1500
  /**
744
1501
  * Read-only migration progress probe for the guardian and `ocx doctor`. Opens sqlite
745
- * readonly with a SHORT busy timeout so a locked DB cannot stall a daemon tick. The
746
- * pending predicate mirrors ejectRemainingOpencodexHistory exactly rows eject ignores
747
- * (empty first_user_message) are not counted, so 0 really means "migration done".
1502
+ * readonly with a SHORT busy timeout so a locked DB cannot stall a daemon tick. Only a
1503
+ * valid, database-bound backup manifest is actionable work; bare routed rows remain
1504
+ * untouched because their original provider is not known. Operator diagnostics keep the
1505
+ * default deep rollout validation. Recurring no-op probes explicitly opt out because any
1506
+ * nonempty manifest already prevents a no-op and the mutation path always preflights files.
748
1507
  */
749
- export function countPendingOpencodexHistory(stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): PendingHistoryCount {
750
- let backupEntries = 0;
751
- try {
752
- const manifest = readBackup(backupPath, stateDbPath);
753
- backupEntries = Object.keys(manifest.entries).length;
754
- } catch { /* unreadable manifest counts as 0 — restore treats it the same way */ }
1508
+ export function countPendingOpencodexHistory(
1509
+ stateDbPath = resolveCodexStateDbPath(),
1510
+ backupPath = historyBackupPathFor(stateDbPath),
1511
+ opts: { validateRestoreTargets?: boolean } = {},
1512
+ ): PendingHistoryCount {
1513
+ const backup = readBackupStrict(backupPath, stateDbPath);
1514
+ if (backup.kind === "unknown") {
1515
+ return {
1516
+ pendingRows: 0,
1517
+ backupEntries: 0,
1518
+ failed: true,
1519
+ failureReason: backup.failureReason ?? "integrity",
1520
+ };
1521
+ }
1522
+ const entries = Object.values(backup.manifest.entries);
1523
+ const backupEntries = entries.length;
755
1524
 
756
- if (!existsSync(stateDbPath)) return { pendingRows: 0, backupEntries };
1525
+ if (!existsSync(stateDbPath)) {
1526
+ return backupEntries > 0
1527
+ ? { pendingRows: 0, backupEntries, failed: true, failureReason: "integrity" }
1528
+ : { pendingRows: 0, backupEntries };
1529
+ }
757
1530
  try {
758
1531
  const db = new Database(stateDbPath, { readonly: true });
759
1532
  try {
760
1533
  db.exec("PRAGMA busy_timeout = 100");
761
- const row = db.query<{ n: number }, []>(`
762
- SELECT count(*) AS n
763
- FROM threads
764
- WHERE model_provider = 'opencodex'
765
- AND trim(coalesce(first_user_message, '')) != ''
766
- `).get();
767
- return { pendingRows: row?.n ?? 0, backupEntries };
1534
+ // Prove the expected history schema is readable without counting unowned routed rows.
1535
+ db.query("SELECT 1 FROM threads LIMIT 1").get();
1536
+ if (entries.length > 0) {
1537
+ const current = db.query<RestoreRowSnapshot, [string]>(`
1538
+ SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message
1539
+ FROM threads WHERE id = ?
1540
+ `);
1541
+ if (opts.validateRestoreTargets === false) {
1542
+ preflightRestoreRows(id => current.get(id), entries);
1543
+ } else {
1544
+ preflightRestoreTargets(id => current.get(id), entries);
1545
+ }
1546
+ }
1547
+ return { pendingRows: 0, backupEntries };
768
1548
  } finally {
769
1549
  db.close();
770
1550
  }
771
1551
  } catch (error) {
772
- if (isRecoverableHistoryError(error)) return { pendingRows: 0, backupEntries, failed: true };
1552
+ const reason = classifyRecoverableHistoryError(error);
1553
+ if (reason) return { pendingRows: 0, backupEntries, failed: true, failureReason: reason };
773
1554
  // Schema drift (e.g. a future codex renames the table) is a "cannot know" too, not a crash.
774
- return { pendingRows: 0, backupEntries, failed: true };
1555
+ return { pendingRows: 0, backupEntries, failed: true, failureReason: "integrity" };
775
1556
  }
776
1557
  }