@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
@@ -0,0 +1,1313 @@
1
+ /**
2
+ * prompt-layers.ts — the Codex prompt-layer surface in `$CODEX_HOME/config.toml`.
3
+ *
4
+ * Scope boundary: this module owns the five `include_*` prompt toggles and the
5
+ * generated `developer_instructions` projection. It is a SIBLING of
6
+ * `features.ts`, not an extension of it: that module's header explicitly forbids
7
+ * broadening itself past `multi_agent_v2`, so the technique is copied here
8
+ * rather than the file being widened.
9
+ *
10
+ * Two design decisions are load-bearing and were forced by an adversarial audit
11
+ * (devlog/_plan/260802_codex_set_prompt_composer/):
12
+ *
13
+ * 1. NO USER PROSE IS PARSED BACK OUT OF TOML. Custom layers live in
14
+ * `opencodex-prompt.json`, which we own outright; config.toml receives a
15
+ * write-only projection of the enabled subset. Layer identity never has to
16
+ * survive a round trip through a TOML parser.
17
+ *
18
+ * 2. NO TOML LIBRARY IS USED TO VERIFY WHAT WE WROTE. Measured on Bun 1.3.14,
19
+ * `Bun.TOML.parse` transposes `\t` and `\f`, rejects `\u0007`, and does not
20
+ * trim the newline after an opening `'''`. Codex parses with Rust
21
+ * `toml_edit`, so verifying through a JS parser could report success on a
22
+ * file Codex reads differently. Instead the accepted character set is
23
+ * restricted until escaping is total under three rules, and verification is
24
+ * a byte comparison.
25
+ *
26
+ * CODEX_HOME is resolved at CALL time (the `features.ts:58-67` pattern) so tests
27
+ * can point fixtures via env or an explicit path.
28
+ */
29
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
30
+ import { dirname, join, resolve } from "node:path";
31
+ import { createHash, randomBytes } from "node:crypto";
32
+ import { expandUserPath } from "../config";
33
+ import { CODEX_CONFIG_PATH } from "./paths";
34
+ import { OCX_SECTION_MARKER } from "./injected-marker";
35
+ import {
36
+ durableWrite,
37
+ durableWriteExclusive,
38
+ durableDelete,
39
+ encodeJournal,
40
+ ensureDir,
41
+ hashBytes,
42
+ recoverIfNeeded as recoverJournal,
43
+ type JournalRecord,
44
+ } from "./prompt-journal";
45
+ import { release, stillHeld, tryAcquire } from "./prompt-lock";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Inventory — ONE definition, consumed by the route and the GUI alike.
49
+ // Classes are the five in devlog 001 §4; the partition is total and disjoint.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export type LayerClass =
53
+ | "base"
54
+ | "config-toggle"
55
+ | "feature-gated"
56
+ | "runtime-conditional"
57
+ | "extension-unknown";
58
+
59
+ export type ToggleId =
60
+ | "permissions"
61
+ | "collaboration"
62
+ | "environment"
63
+ | "apps"
64
+ | "skills";
65
+
66
+ export interface LayerDescriptor {
67
+ id: string;
68
+ class: LayerClass;
69
+ /** config key for config-toggle and feature-gated rows; null otherwise */
70
+ key: string | null;
71
+ /** documented default when the key is absent */
72
+ default: boolean | null;
73
+ /** assembly index from devlog 001 §1; null when registration-order dependent */
74
+ order: number | null;
75
+ }
76
+
77
+ /**
78
+ * Assembly order per `core/src/session/world_state.rs`. `base-instructions` is
79
+ * NOT a world-state section — it travels in the Responses `instructions` field
80
+ * — so it carries order 0 and sits ahead of the rest.
81
+ *
82
+ * `plugins` is `runtime-conditional`, not feature-gated: `core/src/mcp.rs:200`
83
+ * computes `selected_plugin_available || !capability_summaries().is_empty()`,
84
+ * so `[features] plugins` influences the right operand but does not gate
85
+ * emission.
86
+ */
87
+ export const LAYER_INVENTORY: readonly LayerDescriptor[] = Object.freeze([
88
+ { id: "base-instructions", class: "base", key: null, default: null, order: 0 },
89
+ { id: "model-switch", class: "runtime-conditional", key: null, default: null, order: 1 },
90
+ { id: "personality", class: "feature-gated", key: "features.personality", default: true, order: 2 },
91
+ { id: "context-window-guidance", class: "feature-gated", key: "features.token_budget", default: false, order: 3 },
92
+ { id: "realtime", class: "runtime-conditional", key: null, default: null, order: 4 },
93
+ { id: "agents-md", class: "runtime-conditional", key: null, default: null, order: 5 },
94
+ { id: "permissions", class: "config-toggle", key: "include_permissions_instructions", default: true, order: 6 },
95
+ { id: "collaboration", class: "config-toggle", key: "include_collaboration_mode_instructions", default: true, order: 7 },
96
+ { id: "environment", class: "config-toggle", key: "include_environment_context", default: true, order: 8 },
97
+ { id: "environments-instructions", class: "feature-gated", key: "features.deferred_executor", default: false, order: 9 },
98
+ { id: "apps", class: "config-toggle", key: "include_apps_instructions", default: true, order: 10 },
99
+ { id: "plugins", class: "runtime-conditional", key: null, default: null, order: 11 },
100
+ { id: "tools", class: "feature-gated", key: "features.deferred_tool_world_state", default: false, order: 12 },
101
+ { id: "skills", class: "config-toggle", key: "skills.include_instructions", default: true, order: 13 },
102
+ { id: "multi-agent-mode", class: "feature-gated", key: "features.multi_agent_v2.enabled", default: false, order: 14 },
103
+ /**
104
+ * Commit and pull-request attribution, contributed by `ext/git-attribution` rather
105
+ * than by a world_state.rs section — which is why it is absent from the order list
106
+ * above and carries `order: null`: it registers through
107
+ * `extensions.context_contributors()` (`core/src/session/world_state.rs:64-66`),
108
+ * whose position is registration-order dependent.
109
+ *
110
+ * `runtime-conditional`, NOT feature-gated. `ext/git-attribution/src/lib.rs:33-80`
111
+ * resolves enablement from the AUTH SERVER via `resolve_attribution_policy`, caches
112
+ * it on the thread store, and falls back to disabled when the lookup fails.
113
+ * `features/src/lib.rs:277` records the old config flag as removed, so there is no
114
+ * key for this GUI to write and nothing in [features] to point a user at.
115
+ *
116
+ * Both states emit text: enabled sends the `Co-authored-by: Codex` trailer plus the
117
+ * `Generated with Codex.` PR marker, disabled sends an explicit countermand. So the
118
+ * row's condition line must name the policy rather than claiming "always on".
119
+ */
120
+ { id: "git-attribution", class: "runtime-conditional", key: null, default: null, order: null },
121
+ ] as const);
122
+
123
+ /**
124
+ * The write allowlist. Fixed, never computed: `config_toml.rs` does NOT carry
125
+ * serde's `deny_unknown_fields`, so a typo'd key is silently ignored in normal
126
+ * mode and a hard startup error under `--strict-config`. A fixed table means
127
+ * the GUI can never emit a key it did not intend.
128
+ */
129
+ const TOGGLE_KEYS: Record<ToggleId, { table: string | null; key: string }> = {
130
+ permissions: { table: null, key: "include_permissions_instructions" },
131
+ collaboration: { table: null, key: "include_collaboration_mode_instructions" },
132
+ environment: { table: null, key: "include_environment_context" },
133
+ apps: { table: null, key: "include_apps_instructions" },
134
+ skills: { table: "skills", key: "include_instructions" },
135
+ };
136
+
137
+ export const TOGGLE_IDS = Object.freeze(Object.keys(TOGGLE_KEYS) as ToggleId[]);
138
+
139
+ export function isToggleId(value: string): value is ToggleId {
140
+ return Object.prototype.hasOwnProperty.call(TOGGLE_KEYS, value);
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Paths
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export interface Paths {
148
+ configPath?: string;
149
+ storePath?: string;
150
+ baseVariantDir?: string;
151
+ }
152
+
153
+ function activeCodexHome(): string {
154
+ const raw = process.env.CODEX_HOME?.trim();
155
+ if (!raw) return CODEX_CONFIG_PATH.slice(0, -"/config.toml".length);
156
+ const path = resolve(expandUserPath(raw));
157
+ try {
158
+ return realpathSync.native(path);
159
+ } catch {
160
+ return path;
161
+ }
162
+ }
163
+
164
+ export function activeConfigPath(opts?: Paths): string {
165
+ return opts?.configPath ?? join(activeCodexHome(), "config.toml");
166
+ }
167
+
168
+ export function activeStorePath(opts?: Paths): string {
169
+ return opts?.storePath ?? join(activeCodexHome(), "opencodex-prompt.json");
170
+ }
171
+
172
+ /**
173
+ * Where authored base-prompt variants live, one markdown file per variant.
174
+ *
175
+ * A directory of real files rather than another JSON store, because
176
+ * `model_instructions_file` points Codex at a path it reads directly. Embedding the
177
+ * bodies in `opencodex-prompt.json` would mean materialising a temp file at selection
178
+ * time, which is a second write path for no gain.
179
+ */
180
+ export function activeBaseVariantDir(opts?: Paths): string {
181
+ return opts?.baseVariantDir ?? join(activeCodexHome(), "opencodex-prompt-base");
182
+ }
183
+
184
+ function journalPathFor(storePath: string): string {
185
+ return `${storePath.replace(/\.json$/, "")}.journal`;
186
+ }
187
+
188
+ function lockPathFor(storePath: string): string {
189
+ return `${storePath.replace(/\.json$/, "")}.lock`;
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Character policy — see the header. Defined over Unicode SCALAR VALUES, not
194
+ // UTF-16 code units, because a lone surrogate is not a scalar value and UTF-8
195
+ // encoding would silently substitute U+FFFD.
196
+ // ---------------------------------------------------------------------------
197
+
198
+ export interface CharacterFinding {
199
+ /** code-point index, consistent across module, route and editor */
200
+ position: number;
201
+ reason: "control" | "unpaired-surrogate";
202
+ codePoint: number;
203
+ }
204
+
205
+ /** Tab to four spaces, CRLF and lone CR to LF. Applied BEFORE validation. */
206
+ export function normalizeBody(body: string): string {
207
+ return body.replace(/\r\n?/g, "\n").replace(/\t/g, " ");
208
+ }
209
+
210
+ /** First offending scalar, or null. Run AFTER normalizeBody. */
211
+ export function findInvalidCharacter(body: string): CharacterFinding | null {
212
+ let position = 0;
213
+ for (let i = 0; i < body.length; ) {
214
+ const code = body.codePointAt(i)!;
215
+ const unit = body.charCodeAt(i);
216
+ const isHighSurrogate = unit >= 0xd800 && unit <= 0xdbff;
217
+ const isLowSurrogate = unit >= 0xdc00 && unit <= 0xdfff;
218
+ // codePointAt only combines a well-formed pair, so a surviving surrogate
219
+ // code point here is unpaired by construction.
220
+ if ((isHighSurrogate || isLowSurrogate) && code === unit) {
221
+ return { position, reason: "unpaired-surrogate", codePoint: code };
222
+ }
223
+ const isNewline = code === 0x0a;
224
+ const isC0 = code < 0x20 && !isNewline;
225
+ const isDel = code === 0x7f;
226
+ const isC1 = code >= 0x80 && code <= 0x9f;
227
+ if (isC0 || isDel || isC1) {
228
+ return { position, reason: "control", codePoint: code };
229
+ }
230
+ i += code > 0xffff ? 2 : 1;
231
+ position += 1;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * TOML basic-string encoding, total over the accepted set: three rules, none of
238
+ * them in the range where `Bun.TOML.parse` misbehaves. `\r` cannot appear
239
+ * because normalizeBody removed it; control characters cannot appear because
240
+ * findInvalidCharacter rejected them.
241
+ */
242
+ export function encodeBasicString(body: string): string {
243
+ return `"${body.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
244
+ }
245
+
246
+ /**
247
+ * Inverse of `encodeBasicString`, deliberately narrow: it accepts ONLY the three
248
+ * escapes we emit. `\t`, `\f`, `\b`, `\r` and `\uXXXX` are refused rather than
249
+ * guessed — decoding them correctly is exactly the ambiguity the restricted set
250
+ * exists to avoid.
251
+ */
252
+ export function decodeBasicString(literal: string): string | null {
253
+ if (literal.length < 2 || !literal.startsWith('"') || !literal.endsWith('"')) return null;
254
+ const inner = literal.slice(1, -1);
255
+ let out = "";
256
+ for (let i = 0; i < inner.length; i += 1) {
257
+ const ch = inner[i]!;
258
+ if (ch !== "\\") {
259
+ if (ch === '"') return null; // unescaped quote: not a single literal
260
+ out += ch;
261
+ continue;
262
+ }
263
+ const next = inner[i + 1];
264
+ if (next === "\\") out += "\\";
265
+ else if (next === '"') out += '"';
266
+ else if (next === "n") out += "\n";
267
+ else return null; // any other escape is outside what we will decode
268
+ i += 1;
269
+ }
270
+ return out;
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Byte-level hashing. The revision covers COMPLETE file bytes plus existence,
275
+ // so removing the marker while leaving the value intact still changes it.
276
+ // ---------------------------------------------------------------------------
277
+
278
+ function readFileOrNull(path: string): string | null {
279
+ try {
280
+ if (!existsSync(path)) return null;
281
+ return readFileSync(path, "utf8");
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ export function computeRevision(configBytes: string | null, storeBytes: string | null): string {
288
+ const hash = createHash("sha256");
289
+ hash.update("cfg:");
290
+ hash.update(configBytes ?? "\0absent");
291
+ hash.update("\nstore:");
292
+ hash.update(storeBytes ?? "\0absent");
293
+ return `sha256:${hash.digest("hex")}`;
294
+ }
295
+
296
+ export { readFileOrNull as readFileBytes };
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Scoped TOML scanning. Line-based like `features.ts:80-93`: booleans need no
300
+ // escaping, and line editing preserves the user's comments and formatting
301
+ // exactly where a re-serialize would not.
302
+ // ---------------------------------------------------------------------------
303
+
304
+ const TABLE_HEADER = /^\s*\[/;
305
+
306
+ /** Lines of the root scope: everything before the first `[table]` header. */
307
+ function rootLines(content: string): string[] {
308
+ const lines = content.split("\n");
309
+ const first = lines.findIndex(l => TABLE_HEADER.test(l));
310
+ return first === -1 ? lines : lines.slice(0, first);
311
+ }
312
+
313
+ /** Lines of `[header]`'s body, up to the next table header. */
314
+ function tableLines(content: string, header: string): string[] | null {
315
+ const lines = content.split("\n");
316
+ const escaped = header.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
317
+ const start = lines.findIndex(l => new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`).test(l));
318
+ if (start === -1) return null;
319
+ const rest = lines.slice(start + 1);
320
+ const end = rest.findIndex(l => TABLE_HEADER.test(l));
321
+ return end === -1 ? rest : rest.slice(0, end);
322
+ }
323
+
324
+ function boolInLines(lines: string[], key: string): boolean | null {
325
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
326
+ const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*(true|false)\\s*(?:#.*)?$`);
327
+ for (const line of lines) {
328
+ const m = pattern.exec(line);
329
+ if (m) return m[1] === "true";
330
+ }
331
+ return null;
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Ownership of the generated projection.
336
+ //
337
+ // Canonical physical form, always exactly two lines at the top of the document:
338
+ //
339
+ // # Auto-injected by opencodex
340
+ // developer_instructions = "<single-line basic string>"
341
+ //
342
+ // Replacement is "find the marker, replace the next line" — never a span search.
343
+ // Adjacency mirrors `injected-marker.ts:53-60`, tightened by a shape check.
344
+ // ---------------------------------------------------------------------------
345
+
346
+ const DEV_INSTRUCTIONS_KEY = "developer_instructions";
347
+ const CANONICAL_LINE = /^developer_instructions = "(?:[^"\\]|\\.)*"$/;
348
+ const ANY_DEV_INSTRUCTIONS = /^\s*(?:developer_instructions|"developer_instructions"|'developer_instructions')\s*=/;
349
+
350
+ export type Ownership =
351
+ /** no such key anywhere in the root scope */
352
+ | { state: "absent" }
353
+ /** marker-adjacent and canonically shaped: ours to rewrite */
354
+ | { state: "owned"; line: number; literal: string }
355
+ /** marker-adjacent but reshaped: refuse, offer repair */
356
+ | { state: "owned-malformed"; line: number; raw: string }
357
+ /** no marker: externally authored, refuse and offer adoption */
358
+ | { state: "external"; line: number; raw: string };
359
+
360
+ export function inspectOwnership(configBytes: string | null): Ownership {
361
+ if (configBytes === null) return { state: "absent" };
362
+ const lines = rootLines(configBytes);
363
+ for (let i = 0; i < lines.length; i += 1) {
364
+ const raw = lines[i]!;
365
+ if (!ANY_DEV_INSTRUCTIONS.test(raw)) continue;
366
+ const marked = i > 0 && lines[i - 1]!.includes(OCX_SECTION_MARKER);
367
+ if (!marked) return { state: "external", line: i + 1, raw };
368
+ if (!CANONICAL_LINE.test(raw)) return { state: "owned-malformed", line: i + 1, raw };
369
+ const literal = raw.slice(`${DEV_INSTRUCTIONS_KEY} = `.length);
370
+ return { state: "owned", line: i + 1, literal };
371
+ }
372
+ return { state: "absent" };
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Store — the single source of truth for custom layers.
377
+ // ---------------------------------------------------------------------------
378
+
379
+ export interface CustomLayer {
380
+ /** [a-z0-9]{6}, stable across edits */
381
+ id: string;
382
+ title: string;
383
+ body: string;
384
+ enabled: boolean;
385
+ }
386
+
387
+ const LAYER_ID = /^[a-z0-9]{6}$/;
388
+
389
+ function isCustomLayer(value: unknown): value is CustomLayer {
390
+ if (!value || typeof value !== "object") return false;
391
+ const v = value as Record<string, unknown>;
392
+ return typeof v.id === "string" && LAYER_ID.test(v.id)
393
+ && typeof v.title === "string"
394
+ && typeof v.body === "string"
395
+ && typeof v.enabled === "boolean";
396
+ }
397
+
398
+ /** null means unreadable/malformed, which is NOT the same as an empty store. */
399
+ export function parseStore(storeBytes: string | null): CustomLayer[] | null {
400
+ if (storeBytes === null) return null;
401
+ let parsed: unknown;
402
+ try {
403
+ parsed = JSON.parse(storeBytes);
404
+ } catch {
405
+ return null;
406
+ }
407
+ if (!parsed || typeof parsed !== "object") return null;
408
+ const layers = (parsed as { layers?: unknown }).layers;
409
+ if (!Array.isArray(layers) || !layers.every(isCustomLayer)) return null;
410
+ const ids = new Set(layers.map(l => l.id));
411
+ if (ids.size !== layers.length) return null;
412
+ return layers;
413
+ }
414
+
415
+ /** Enabled layers, joined in order. This is the value written to config.toml. */
416
+ export function composeProjection(layers: readonly CustomLayer[]): string {
417
+ return layers.filter(l => l.enabled).map(l => l.body).join("\n\n");
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // Snapshot
422
+ // ---------------------------------------------------------------------------
423
+
424
+ export interface ToggleState {
425
+ id: ToggleId;
426
+ key: string;
427
+ /** null = the key is absent from the user file */
428
+ userFileValue: boolean | null;
429
+ /**
430
+ * userFileValue ?? default. NOT the resolved Codex value: opencodex reads one
431
+ * of the eight config layers, so it reports this file's value under a name
432
+ * that says as much.
433
+ */
434
+ defaultedUserValue: boolean;
435
+ default: boolean;
436
+ }
437
+
438
+ export type Drift =
439
+ | "journal-present"
440
+ | "projection-stale"
441
+ | "store-missing"
442
+ | "owned-malformed"
443
+ | null;
444
+
445
+ /** One authored base-prompt variant. `default` is never represented here. */
446
+ export interface BaseVariant {
447
+ id: string;
448
+ title: string;
449
+ body: string;
450
+ bytes: number;
451
+ }
452
+
453
+ /**
454
+ * Which base prompt is in force. THREE values, not two.
455
+ *
456
+ * - `default` — `model_instructions_file` is absent, so Codex uses its own base prompt.
457
+ * This is the absence of a key, not a body we store: there is nothing to edit and
458
+ * nothing to delete, which is what makes the default structurally immutable rather
459
+ * than merely guarded.
460
+ * - a variant id — the key points inside our own variant directory.
461
+ * - `external` — the key is set and points somewhere else.
462
+ *
463
+ * The third value is load-bearing and an audit forced it. Collapsing it into `default`
464
+ * would have shown a user "Codex's own base prompt" while their base prompt was in fact
465
+ * replaced by a file they had set by hand. The panel already ships a notice for that
466
+ * state in ten locales; this keeps reporting it instead of overwriting a key we do not
467
+ * own.
468
+ */
469
+ export type BaseSelection = { kind: "default" } | { kind: "variant"; id: string } | { kind: "external"; path: string };
470
+
471
+ export interface PromptLayerSnapshot {
472
+ configPath: string;
473
+ storePath: string;
474
+ configExists: boolean;
475
+ readable: boolean;
476
+ developerInstructionsOwned: boolean;
477
+ /** non-null blocks mutations until resolved */
478
+ drift: Drift;
479
+ toggles: ToggleState[];
480
+ custom: CustomLayer[];
481
+ modelInstructionsFile: string | null;
482
+ baseVariants: BaseVariant[];
483
+ baseSelection: BaseSelection;
484
+ revision: string;
485
+ }
486
+
487
+ function readToggle(configBytes: string | null, id: ToggleId): ToggleState {
488
+ const spec = TOGGLE_KEYS[id];
489
+ const descriptor = LAYER_INVENTORY.find(d => d.id === id)!;
490
+ const fallback = descriptor.default ?? true;
491
+ const key = spec.table ? `${spec.table}.${spec.key}` : spec.key;
492
+ let value: boolean | null = null;
493
+ if (configBytes !== null) {
494
+ const scope = spec.table ? tableLines(configBytes, spec.table) : rootLines(configBytes);
495
+ value = scope === null ? null : boolInLines(scope, spec.key);
496
+ }
497
+ return {
498
+ id,
499
+ key,
500
+ userFileValue: value,
501
+ defaultedUserValue: value ?? fallback,
502
+ default: fallback,
503
+ };
504
+ }
505
+
506
+ function readModelInstructionsFile(configBytes: string | null): string | null {
507
+ if (configBytes === null) return null;
508
+ for (const line of rootLines(configBytes)) {
509
+ const m = /^\s*model_instructions_file\s*=\s*"([^"]*)"\s*(?:#.*)?$/.exec(line);
510
+ if (m) return m[1]!;
511
+ }
512
+ return null;
513
+ }
514
+
515
+ /** Variant ids are ours to generate, so they stay in one narrow shape. */
516
+ const BASE_VARIANT_ID = /^[a-z0-9]{6}$/;
517
+
518
+ /**
519
+ * The variant files on disk, newest-id-last so the picker order is stable.
520
+ *
521
+ * `default.md` is SKIPPED rather than read: `default` names the absence of a key, so a
522
+ * file claiming that id would appear as a fourth variant whose selection could never be
523
+ * expressed. Anything not matching our own id shape is skipped for the same reason - we
524
+ * only report what we could also write.
525
+ */
526
+ export function readBaseVariants(opts?: Paths): BaseVariant[] {
527
+ const dir = activeBaseVariantDir(opts);
528
+ let names: string[];
529
+ try {
530
+ names = readdirSync(dir);
531
+ } catch {
532
+ // Absent directory is an ordinary first run, not an error.
533
+ return [];
534
+ }
535
+ const out: BaseVariant[] = [];
536
+ for (const name of names.sort()) {
537
+ if (!name.endsWith(".md")) continue;
538
+ const id = name.slice(0, -3);
539
+ if (!BASE_VARIANT_ID.test(id)) continue;
540
+ const body = readFileOrNull(join(dir, name));
541
+ if (body === null) continue;
542
+ // First line is the title when it is a markdown heading; the rest is the prompt.
543
+ // Storing the title inside the file keeps one artifact per variant instead of a
544
+ // sidecar index that can disagree with it.
545
+ const nl = body.indexOf("\n");
546
+ const firstLine = nl === -1 ? body : body.slice(0, nl);
547
+ const titled = firstLine.startsWith("# ");
548
+ out.push({
549
+ id,
550
+ title: titled ? firstLine.slice(2).trim() : id,
551
+ body: titled ? body.slice(nl === -1 ? body.length : nl + 1) : body,
552
+ bytes: Buffer.byteLength(body, "utf8"),
553
+ });
554
+ }
555
+ return out;
556
+ }
557
+
558
+ /**
559
+ * Resolve which base prompt is in force, given the config bytes and the variants.
560
+ *
561
+ * Comparison is by RESOLVED path: `~/.codex/opencodex-prompt-base/abc123.md` and an
562
+ * absolute spelling of the same file are the same selection, and treating them as
563
+ * different would report `external` for a variant we wrote ourselves.
564
+ */
565
+ export function resolveBaseSelection(
566
+ configBytes: string | null,
567
+ variants: readonly BaseVariant[],
568
+ opts?: Paths,
569
+ ): BaseSelection {
570
+ const raw = readModelInstructionsFile(configBytes);
571
+ if (raw === null) return { kind: "default" };
572
+ const dir = activeBaseVariantDir(opts);
573
+ let resolved: string;
574
+ try {
575
+ resolved = resolve(expandUserPath(raw));
576
+ } catch {
577
+ return { kind: "external", path: raw };
578
+ }
579
+ for (const variant of variants) {
580
+ if (resolved === resolve(join(dir, `${variant.id}.md`))) {
581
+ return { kind: "variant", id: variant.id };
582
+ }
583
+ }
584
+ return { kind: "external", path: raw };
585
+ }
586
+
587
+ /**
588
+ * Pure. Never writes, never locks, never recovers — a GET must not modify a
589
+ * user's configuration, so drift is REPORTED here and resolved elsewhere.
590
+ */
591
+ export function readPromptLayers(opts?: Paths): PromptLayerSnapshot {
592
+ const configPath = activeConfigPath(opts);
593
+ const storePath = activeStorePath(opts);
594
+ const configExists = existsSync(configPath);
595
+ const configBytes = readFileOrNull(configPath);
596
+ const storeExists = existsSync(storePath);
597
+ const storeBytes = readFileOrNull(storePath);
598
+
599
+ // Present but unreadable is a hard stop; absent is an ordinary first run.
600
+ const readable = !configExists || configBytes !== null;
601
+ const ownership = inspectOwnership(configBytes);
602
+ const layers = parseStore(storeBytes);
603
+ const projection = ownership.state === "owned"
604
+ ? decodeBasicString(ownership.literal)
605
+ : null;
606
+ const baseVariants = readBaseVariants(opts);
607
+
608
+ let drift: Drift = null;
609
+ if (existsSync(`${storePath.replace(/\.json$/, "")}.journal`)) {
610
+ drift = "journal-present";
611
+ } else if (ownership.state === "owned-malformed") {
612
+ drift = "owned-malformed";
613
+ } else if (!storeExists && projection !== null && projection.length > 0) {
614
+ // The store is gone while a live projection remains. Treating this as an
615
+ // empty store would erase the active prompt on the next write.
616
+ drift = "store-missing";
617
+ } else if (layers !== null && projection !== null && composeProjection(layers) !== projection) {
618
+ drift = "projection-stale";
619
+ }
620
+
621
+ return {
622
+ configPath,
623
+ storePath,
624
+ configExists,
625
+ readable,
626
+ developerInstructionsOwned: ownership.state === "owned",
627
+ drift,
628
+ toggles: TOGGLE_IDS.map(id => readToggle(configBytes, id)),
629
+ custom: layers ?? [],
630
+ modelInstructionsFile: readModelInstructionsFile(configBytes),
631
+ baseVariants,
632
+ baseSelection: resolveBaseSelection(configBytes, baseVariants, opts),
633
+ revision: computeRevision(configBytes, storeBytes),
634
+ };
635
+ }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // Writing
639
+ // ---------------------------------------------------------------------------
640
+
641
+ export type WriteError =
642
+ | "config_unreadable"
643
+ | "stale_revision"
644
+ | "developer_instructions_not_owned"
645
+ | "unknown_layer"
646
+ | "store_unreadable"
647
+ | "invalid_characters"
648
+ | "write_superseded"
649
+ // The filesystem refused a rename that passed every precondition: a directory on
650
+ // the store path, a mode change, a full disk. Distinct from write_superseded,
651
+ // which means another writer won a race — here nobody won and nothing landed.
652
+ | "write_failed"
653
+ | "recovery_required"
654
+ | "locked";
655
+
656
+ export type WriteResult =
657
+ | { ok: true; changed: boolean; snapshot: PromptLayerSnapshot }
658
+ | { ok: false; error: WriteError; detail?: string };
659
+
660
+ /** Line editing, not re-serialization: the user's comments and layout survive. */
661
+ function dominantEol(content: string): "\r\n" | "\n" {
662
+ const crlf = (content.match(/\r\n/g) ?? []).length;
663
+ if (crlf === 0) return "\n";
664
+ const bareLf = (content.match(/\n/g) ?? []).length - crlf;
665
+ return crlf >= bareLf ? "\r\n" : "\n";
666
+ }
667
+
668
+ function splitLines(content: string): string[] {
669
+ return content.replace(/\r\n/g, "\n").split("\n");
670
+ }
671
+
672
+ /**
673
+ * A leading UTF-8 BOM, split off so line editing never steps over it.
674
+ *
675
+ * Codex reads config.toml with Rust `toml_edit`, which accepts a BOM at byte 0 and
676
+ * nowhere else. Inserting the generated block at line index 0 pushed the BOM down
677
+ * to byte 58, the write reported success because our own byte comparison matched
678
+ * what we intended to write, and the next parse failed with
679
+ * "Expected a key but found (0xEF)" — a config file the user could no longer load,
680
+ * produced by a write that told them it worked.
681
+ *
682
+ * Editors on Windows write this byte routinely, so the file is not exotic.
683
+ */
684
+ function splitBom(content: string): { bom: string; body: string } {
685
+ return content.startsWith("\ufeff")
686
+ ? { bom: "\ufeff", body: content.slice(1) }
687
+ : { bom: "", body: content };
688
+ }
689
+
690
+ function joinLines(lines: string[], eol: "\r\n" | "\n"): string {
691
+ const text = lines.join("\n");
692
+ return eol === "\n" ? text : text.replace(/\n/g, "\r\n");
693
+ }
694
+
695
+ function firstTableIndex(lines: string[]): number {
696
+ const idx = lines.findIndex(l => TABLE_HEADER.test(l));
697
+ return idx === -1 ? lines.length : idx;
698
+ }
699
+
700
+ /** Set a root-scope boolean, inserting above the first table when absent. */
701
+ function setRootBool(content: string, key: string, value: boolean): string {
702
+ const eol = dominantEol(content);
703
+ const { bom, body } = splitBom(content);
704
+ const lines = splitLines(body);
705
+ const limit = firstTableIndex(lines);
706
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
707
+ const pattern = new RegExp(`^(\\s*${escaped}\\s*=\\s*)(?:true|false)(\\s*(?:#.*)?)$`);
708
+ for (let i = 0; i < limit; i += 1) {
709
+ const m = pattern.exec(lines[i]!);
710
+ if (m) {
711
+ lines[i] = `${m[1]}${value}${m[2]}`;
712
+ return bom + joinLines(lines, eol);
713
+ }
714
+ }
715
+ lines.splice(limit, 0, `${key} = ${value}`);
716
+ return bom + joinLines(lines, eol);
717
+ }
718
+
719
+ /**
720
+ * Set or REMOVE a root-scope basic string. `null` removes the key.
721
+ *
722
+ * Removal is what selecting the default variant does, and it has to be a real deletion
723
+ * rather than an empty string: `model_instructions_file = ""` is a path Codex would try
724
+ * to read, not an absent setting.
725
+ */
726
+ function setRootString(content: string, key: string, value: string | null): string {
727
+ const eol = dominantEol(content);
728
+ const { bom, body } = splitBom(content);
729
+ const lines = splitLines(body);
730
+ const limit = firstTableIndex(lines);
731
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
732
+ const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*"[^"]*"\\s*(?:#.*)?$`);
733
+ for (let i = 0; i < limit; i += 1) {
734
+ if (!pattern.test(lines[i]!)) continue;
735
+ if (value === null) lines.splice(i, 1);
736
+ else lines[i] = `${key} = ${encodeBasicString(value)}`;
737
+ return bom + joinLines(lines, eol);
738
+ }
739
+ if (value === null) return bom + joinLines(lines, eol);
740
+ lines.splice(limit, 0, `${key} = ${encodeBasicString(value)}`);
741
+ return bom + joinLines(lines, eol);
742
+ }
743
+
744
+ /** Set a boolean inside `[table]`, appending the table when absent. */
745
+ function setTableBool(content: string, table: string, key: string, value: boolean): string {
746
+ const eol = dominantEol(content);
747
+ const { bom, body } = splitBom(content);
748
+ const lines = splitLines(body);
749
+ const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
750
+ const start = lines.findIndex(l => new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`).test(l));
751
+ if (start === -1) {
752
+ const tail = lines.length > 0 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
753
+ lines.splice(tail, 0, `[${table}]`, `${key} = ${value}`);
754
+ return bom + joinLines(lines, eol);
755
+ }
756
+ const keyEscaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
757
+ const pattern = new RegExp(`^(\\s*${keyEscaped}\\s*=\\s*)(?:true|false)(\\s*(?:#.*)?)$`);
758
+ let end = start + 1;
759
+ while (end < lines.length && !TABLE_HEADER.test(lines[end]!)) end += 1;
760
+ for (let i = start + 1; i < end; i += 1) {
761
+ const m = pattern.exec(lines[i]!);
762
+ if (m) {
763
+ lines[i] = `${m[1]}${value}${m[2]}`;
764
+ return bom + joinLines(lines, eol);
765
+ }
766
+ }
767
+ lines.splice(end, 0, `${key} = ${value}`);
768
+ return bom + joinLines(lines, eol);
769
+ }
770
+
771
+ /**
772
+ * Replace, insert, or remove the generated two-line block. Canonical form is
773
+ * marker + assignment at the top of the document; replacement is "find the
774
+ * marker, replace the next line" rather than a span search.
775
+ */
776
+ function setProjection(content: string | null, projection: string | null): string {
777
+ const base = content ?? "";
778
+ const eol = dominantEol(base);
779
+ // The BOM is held aside for the whole edit. This is the function that produced
780
+ // the corruption: the insert below is at index 0, which put the marker line
781
+ // ahead of a byte that is only legal at byte 0.
782
+ const { bom, body } = splitBom(base);
783
+ const lines = splitLines(body);
784
+ const limit = firstTableIndex(lines);
785
+
786
+ let markerAt = -1;
787
+ for (let i = 0; i < limit; i += 1) {
788
+ if (i > 0 && lines[i - 1]!.includes(OCX_SECTION_MARKER) && ANY_DEV_INSTRUCTIONS.test(lines[i]!)) {
789
+ markerAt = i - 1;
790
+ break;
791
+ }
792
+ }
793
+
794
+ if (markerAt !== -1) {
795
+ if (projection === null) lines.splice(markerAt, 2);
796
+ else lines[markerAt + 1] = `${DEV_INSTRUCTIONS_KEY} = ${encodeBasicString(projection)}`;
797
+ return bom + joinLines(lines, eol);
798
+ }
799
+
800
+ if (projection === null) return bom + joinLines(lines, eol);
801
+ lines.splice(0, 0, OCX_SECTION_MARKER, `${DEV_INSTRUCTIONS_KEY} = ${encodeBasicString(projection)}`);
802
+ return bom + joinLines(lines, eol);
803
+ }
804
+
805
+ function serializeStore(layers: readonly CustomLayer[]): string {
806
+ return `${JSON.stringify({ layers }, null, 2)}\n`;
807
+ }
808
+
809
+ interface Mutation {
810
+ nextConfig: string | null;
811
+ nextStore: string | null;
812
+ }
813
+
814
+ /**
815
+ * The seven-step transaction. Every filesystem mutation in this module goes
816
+ * through here so the journal, the lock, and the per-target byte checks cannot
817
+ * be bypassed by a future caller.
818
+ */
819
+ function commit(
820
+ opts: Paths | undefined,
821
+ revision: string,
822
+ build: (snapshot: PromptLayerSnapshot, configBytes: string | null, storeBytes: string | null)
823
+ => Mutation | { error: WriteError; detail?: string },
824
+ ): WriteResult {
825
+ const configPath = activeConfigPath(opts);
826
+ const storePath = activeStorePath(opts);
827
+ const journalPath = journalPathFor(storePath);
828
+ const lockPath = lockPathFor(storePath);
829
+
830
+ ensureDir(configPath);
831
+ ensureDir(storePath);
832
+
833
+ const acquired = tryAcquire(lockPath);
834
+ if (!acquired.ok) return { ok: false, error: "locked" };
835
+ const handle = acquired.handle;
836
+
837
+ try {
838
+ // 1. recovery first: a journal on disk means an earlier attempt never
839
+ // committed, and we must not stack a second transaction on top of it.
840
+ const recovered = recoverJournal(journalPath, { configPath, storePath });
841
+ if (!recovered.ok) return { ok: false, error: "recovery_required", detail: recovered.detail };
842
+
843
+ // 2. re-read and compare against the caller's edit base.
844
+ const configBytes = readFileOrNull(configPath);
845
+ const storeBytes = readFileOrNull(storePath);
846
+ if (existsSync(configPath) && configBytes === null) {
847
+ return { ok: false, error: "config_unreadable" };
848
+ }
849
+ if (computeRevision(configBytes, storeBytes) !== revision) {
850
+ return { ok: false, error: "stale_revision" };
851
+ }
852
+
853
+ const snapshot = readPromptLayers({ ...opts, configPath, storePath });
854
+ const built = build(snapshot, configBytes, storeBytes);
855
+ if ("error" in built) return { ok: false, error: built.error, detail: built.detail };
856
+
857
+ const { nextConfig, nextStore } = built;
858
+ const configChanged = nextConfig !== configBytes;
859
+ const storeChanged = nextStore !== storeBytes;
860
+ if (!configChanged && !storeChanged) {
861
+ return { ok: true, changed: false, snapshot };
862
+ }
863
+
864
+ // 3. journal the intent. Its existence is NOT a commit.
865
+ const record: JournalRecord = {
866
+ configPath,
867
+ storePath,
868
+ preConfig: hashBytes(configBytes),
869
+ postConfig: hashBytes(nextConfig),
870
+ preStore: hashBytes(storeBytes),
871
+ postStore: hashBytes(nextStore),
872
+ preConfigBytes: configBytes,
873
+ postConfigBytes: nextConfig,
874
+ preStoreBytes: storeBytes,
875
+ postStoreBytes: nextStore,
876
+ };
877
+ durableWrite(journalPath, encodeJournal(record));
878
+
879
+ // 4/5. each target re-verifies ITS OWN bytes immediately before its rename,
880
+ // so a third party writing between step 2 and here is not overwritten.
881
+ //
882
+ // Wrapped, because a THROW here used to escape the transaction entirely.
883
+ // Only `config` readability is pre-checked, so an unwritable STORE — a
884
+ // directory sitting on its path, a permission change, a full disk — raised
885
+ // out of `durableWrite` after the config had already been renamed into
886
+ // place. The caller saw an exception, the config carried a projection whose
887
+ // store did not exist, and the journal stayed behind claiming an
888
+ // uncommitted intent. Every later write then failed recovery_required.
889
+ //
890
+ // Rolling back on the way out restores the pre-state we recorded and drops
891
+ // the journal, so a failed write leaves the pair exactly as it was found.
892
+ try {
893
+ if (configChanged) {
894
+ if (hashBytes(readFileOrNull(configPath)) !== record.preConfig) {
895
+ return rollback(record, journalPath, "stale_revision");
896
+ }
897
+ if (nextConfig === null) durableDelete(configPath);
898
+ else durableWrite(configPath, nextConfig);
899
+ }
900
+ if (storeChanged) {
901
+ if (hashBytes(readFileOrNull(storePath)) !== record.preStore) {
902
+ return rollback(record, journalPath, "stale_revision");
903
+ }
904
+ if (nextStore === null) durableDelete(storePath);
905
+ else durableWrite(storePath, nextStore);
906
+ }
907
+ } catch (error) {
908
+ // `rollback` is byte-hash driven and refuses to touch a file it does not
909
+ // recognise, so it is safe to run against a partially applied pair. If it
910
+ // cannot account for what it finds it returns recovery_required, which is the
911
+ // honest answer — better than a silent half-write either way.
912
+ const undone = rollback(record, journalPath, "write_failed");
913
+ return { ...undone, detail: error instanceof Error ? error.message : String(error) } as WriteResult;
914
+ }
915
+
916
+ // 6. verify COMPLETE bytes, not just our two lines: another writer could
917
+ // change an unrelated key and a narrow check would report success.
918
+ const finalConfig = hashBytes(readFileOrNull(configPath));
919
+ const finalStore = hashBytes(readFileOrNull(storePath));
920
+ if (finalConfig !== record.postConfig || finalStore !== record.postStore) {
921
+ return { ok: false, error: "write_superseded" };
922
+ }
923
+ if (!stillHeld(handle)) return { ok: false, error: "write_superseded" };
924
+
925
+ durableDelete(journalPath); // this deletion is the commit
926
+ // The FULL opts, not just the two paths this transaction owns: rebuilding the
927
+ // snapshot from a narrowed object dropped the injected variant directory, so every
928
+ // successful write reported an empty variant list back to its caller.
929
+ return { ok: true, changed: true, snapshot: readPromptLayers({ ...opts, configPath, storePath }) };
930
+ } finally {
931
+ release(handle);
932
+ }
933
+ }
934
+
935
+ /** Undo whatever landed, then drop the journal. Never touches an unknown file. */
936
+ function rollback(record: JournalRecord, journalPath: string, error: WriteError): WriteResult {
937
+ const configNow = hashBytes(readFileOrNull(record.configPath));
938
+ const storeNow = hashBytes(readFileOrNull(record.storePath));
939
+ if (configNow !== record.preConfig && configNow !== record.postConfig) {
940
+ return { ok: false, error: "recovery_required", detail: record.configPath };
941
+ }
942
+ if (storeNow !== record.preStore && storeNow !== record.postStore) {
943
+ return { ok: false, error: "recovery_required", detail: record.storePath };
944
+ }
945
+ if (configNow === record.postConfig) {
946
+ if (record.preConfigBytes === null) durableDelete(record.configPath);
947
+ else durableWrite(record.configPath, record.preConfigBytes);
948
+ }
949
+ if (storeNow === record.postStore) {
950
+ if (record.preStoreBytes === null) durableDelete(record.storePath);
951
+ else durableWrite(record.storePath, record.preStoreBytes);
952
+ }
953
+ durableDelete(journalPath);
954
+ return { ok: false, error };
955
+ }
956
+
957
+ /** Flip one of the five prompt toggles. */
958
+ export function setToggle(id: string, enabled: boolean, revision: string, opts?: Paths): WriteResult {
959
+ if (!isToggleId(id)) return { ok: false, error: "unknown_layer" };
960
+ const spec = TOGGLE_KEYS[id];
961
+ return commit(opts, revision, (_snapshot, configBytes, storeBytes) => ({
962
+ nextConfig: spec.table
963
+ ? setTableBool(configBytes ?? "", spec.table, spec.key, enabled)
964
+ : setRootBool(configBytes ?? "", spec.key, enabled),
965
+ nextStore: storeBytes,
966
+ }));
967
+ }
968
+
969
+ /**
970
+ * Point `model_instructions_file` at a variant, or remove it for the default.
971
+ *
972
+ * Refusals, each for a reason the GUI cannot be trusted to enforce alone:
973
+ * - an unknown variant id, because the key would name a file Codex cannot read;
974
+ * - the `external` state, because retargeting a key somebody else set silently
975
+ * discards their base prompt. Adopting it is a separate, explicit act.
976
+ */
977
+ export function selectBaseVariant(selection: BaseSelection, revision: string, opts?: Paths): WriteResult {
978
+ if (selection.kind === "external") return { ok: false, error: "unknown_layer", detail: "cannot select the external state" };
979
+ const dir = activeBaseVariantDir(opts);
980
+ return commit(opts, revision, (snapshot, configBytes, storeBytes) => {
981
+ if (snapshot.baseSelection.kind === "external") {
982
+ return { error: "developer_instructions_not_owned", detail: snapshot.baseSelection.path };
983
+ }
984
+ if (selection.kind === "variant") {
985
+ const variant = snapshot.baseVariants.find(v => v.id === selection.id);
986
+ if (!variant) return { error: "unknown_layer", detail: selection.id };
987
+ }
988
+ const next = selection.kind === "default"
989
+ ? null
990
+ : resolve(join(dir, `${selection.id}.md`));
991
+ return {
992
+ nextConfig: setRootString(configBytes ?? "", "model_instructions_file", next),
993
+ nextStore: storeBytes,
994
+ };
995
+ });
996
+ }
997
+
998
+ /** How many authored variants a user may keep. Two plus the default is the ask. */
999
+ export const MAX_BASE_VARIANTS = 2;
1000
+
1001
+ /**
1002
+ * Write or delete one authored variant body.
1003
+ *
1004
+ * Ordering is deliberate and was learned from a defect in this same module: the FILE is
1005
+ * written and verified before `config.toml` is ever pointed at it. Pointing first would
1006
+ * leave the key naming a file that may not exist, which is a worse failure than a written
1007
+ * file nothing references yet.
1008
+ *
1009
+ * Deleting the variant that is currently SELECTED also clears the key in the same
1010
+ * transaction, so the config can never outlive the file it names.
1011
+ */
1012
+ export function writeBaseVariant(
1013
+ input: { id: string | null; title: string; body: string } | { id: string; delete: true },
1014
+ revision: string,
1015
+ opts?: Paths,
1016
+ ): WriteResult {
1017
+ const dir = activeBaseVariantDir(opts);
1018
+ const deleting = "delete" in input;
1019
+ if (!deleting) {
1020
+ const normalized = normalizeBody(input.body);
1021
+ const invalid = findInvalidCharacter(normalized);
1022
+ if (invalid !== null) {
1023
+ return { ok: false, error: "invalid_characters", detail: `at code point ${invalid.position}` };
1024
+ }
1025
+ }
1026
+ const existing = readBaseVariants(opts);
1027
+ const targetId = deleting
1028
+ ? input.id
1029
+ : input.id ?? newBaseVariantId(existing);
1030
+ if (!BASE_VARIANT_ID.test(targetId)) return { ok: false, error: "unknown_layer", detail: targetId };
1031
+ if (deleting && !existing.some(v => v.id === targetId)) {
1032
+ return { ok: false, error: "unknown_layer", detail: targetId };
1033
+ }
1034
+ if (!deleting && input.id === null && existing.length >= MAX_BASE_VARIANTS) {
1035
+ return { ok: false, error: "unknown_layer", detail: `at most ${MAX_BASE_VARIANTS} variants` };
1036
+ }
1037
+ const path = join(dir, `${targetId}.md`);
1038
+ const before = readFileOrNull(path);
1039
+ const next = deleting
1040
+ ? null
1041
+ : `# ${input.title.replace(/[\r\n]+/g, " ").trim() || targetId}\n${normalizeBody(input.body)}`;
1042
+
1043
+ // Whether this id is the live selection has to be decided while the file still
1044
+ // EXISTS. Deleting first made `resolveBaseSelection` fall through to `external` - the
1045
+ // path no longer matched a known variant - so the config half saw a state it refuses
1046
+ // to touch and left the key pointing at a file that was already gone.
1047
+ const selectedBefore = resolveBaseSelection(readFileOrNull(activeConfigPath(opts)), existing, opts);
1048
+ const clearingKey = deleting
1049
+ && selectedBefore.kind === "variant"
1050
+ && selectedBefore.id === targetId;
1051
+
1052
+ // On a CREATE or EDIT the file goes first: pointing config.toml at a file that does
1053
+ // not exist yet is worse than writing a file nothing references. On a DELETE the
1054
+ // order is reversed for the same reason read the other way - the key must stop
1055
+ // naming the file before the file disappears.
1056
+ if (!deleting) {
1057
+ ensureDir(path);
1058
+ try {
1059
+ durableWrite(path, next!);
1060
+ } catch (error) {
1061
+ return { ok: false, error: "write_failed", detail: error instanceof Error ? error.message : String(error) };
1062
+ }
1063
+ }
1064
+
1065
+ const result = commit(opts, revision, (_snapshot, configBytes, storeBytes) => ({
1066
+ nextConfig: clearingKey
1067
+ ? setRootString(configBytes ?? "", "model_instructions_file", null)
1068
+ : configBytes,
1069
+ nextStore: storeBytes,
1070
+ }));
1071
+
1072
+ if (!result.ok) {
1073
+ // Undo the file half rather than leaving a variant the caller was told was not
1074
+ // written. A delete has not touched the file yet, so there is nothing to undo.
1075
+ if (!deleting) {
1076
+ try {
1077
+ if (before === null) durableDelete(path);
1078
+ else durableWrite(path, before);
1079
+ } catch { /* the returned error already tells the caller to look */ }
1080
+ }
1081
+ return result;
1082
+ }
1083
+
1084
+ if (deleting) {
1085
+ try {
1086
+ durableDelete(path);
1087
+ } catch (error) {
1088
+ // The key is already clear, so the prompt is correct; the stale file is inert.
1089
+ return { ok: false, error: "write_failed", detail: error instanceof Error ? error.message : String(error) };
1090
+ }
1091
+ // Re-read so the caller sees the variant actually gone.
1092
+ return { ok: true, changed: true, snapshot: readPromptLayers(opts) };
1093
+ }
1094
+ return result;
1095
+ }
1096
+
1097
+ function newBaseVariantId(existing: readonly BaseVariant[]): string {
1098
+ const taken = new Set(existing.map(v => v.id));
1099
+ for (;;) {
1100
+ const id = randomBytes(4).toString("hex").slice(0, 6);
1101
+ if (!taken.has(id)) return id;
1102
+ }
1103
+ }
1104
+
1105
+ /** Replace the whole custom-layer list; order is composition order. */
1106
+ export function writeCustomLayers(layers: readonly CustomLayer[], revision: string, opts?: Paths): WriteResult {
1107
+ for (const layer of layers) {
1108
+ const normalized = normalizeBody(layer.body);
1109
+ const invalid = findInvalidCharacter(normalized);
1110
+ if (invalid !== null) {
1111
+ return { ok: false, error: "invalid_characters", detail: `layer ${layer.id} at code point ${invalid.position}` };
1112
+ }
1113
+ }
1114
+ const normalizedLayers = layers.map(l => ({ ...l, body: normalizeBody(l.body) }));
1115
+ return commit(opts, revision, (snapshot, configBytes, _storeBytes) => {
1116
+ // Only a marker-owned key may be rewritten. Absent is fine — we create it.
1117
+ const ownership = inspectOwnership(configBytes);
1118
+ if (ownership.state === "external" || ownership.state === "owned-malformed") {
1119
+ return { error: "developer_instructions_not_owned" };
1120
+ }
1121
+ const projection = composeProjection(normalizedLayers);
1122
+ return {
1123
+ nextConfig: setProjection(configBytes, projection.length > 0 ? projection : null),
1124
+ nextStore: serializeStore(normalizedLayers),
1125
+ };
1126
+ });
1127
+ }
1128
+
1129
+ // ---------------------------------------------------------------------------
1130
+ // Adoption — taking ownership of an externally authored key.
1131
+ //
1132
+ // Refusing alone was a dead end: the earlier answer was "delete your existing
1133
+ // instructions by hand", which is not a feature. Adoption previews the raw line
1134
+ // AND the exact body that will be committed, then imports on confirmation.
1135
+ // ---------------------------------------------------------------------------
1136
+
1137
+ export interface AdoptPreview {
1138
+ rawLine: string | null;
1139
+ /** post-normalization body — byte-identical to what a confirm would store */
1140
+ decodedBody: string | null;
1141
+ reason: "ok" | "nothing_to_adopt" | "unsupported_form" | "invalid_characters";
1142
+ path: string;
1143
+ line: number | null;
1144
+ detail?: string;
1145
+ }
1146
+
1147
+ function newLayerId(existing: readonly CustomLayer[]): string {
1148
+ const taken = new Set(existing.map(l => l.id));
1149
+ for (;;) {
1150
+ const id = randomBytes(4).toString("hex").slice(0, 6);
1151
+ if (!taken.has(id)) return id;
1152
+ }
1153
+ }
1154
+
1155
+ /**
1156
+ * Read-only. Runs the same five steps a confirm would, so preview and commit
1157
+ * cannot disagree: decode -> normalize -> validate -> cap -> present.
1158
+ */
1159
+ export function previewAdopt(opts?: Paths): AdoptPreview {
1160
+ const configPath = activeConfigPath(opts);
1161
+ const ownership = inspectOwnership(readFileOrNull(configPath));
1162
+
1163
+ if (ownership.state === "absent" || ownership.state === "owned") {
1164
+ return { rawLine: null, decodedBody: null, reason: "nothing_to_adopt", path: configPath, line: null };
1165
+ }
1166
+
1167
+ const raw = ownership.raw;
1168
+ const line = ownership.line;
1169
+ const eq = raw.indexOf("=");
1170
+ const literal = eq === -1 ? "" : raw.slice(eq + 1).trim().replace(/\s*#.*$/, "");
1171
+ const decoded = decodeBasicString(literal);
1172
+ if (decoded === null) {
1173
+ return {
1174
+ rawLine: raw,
1175
+ decodedBody: null,
1176
+ reason: "unsupported_form",
1177
+ path: configPath,
1178
+ line,
1179
+ detail: "only a single-line basic string using \\\", \\\\ and \\n can be decoded safely",
1180
+ };
1181
+ }
1182
+
1183
+ const normalized = normalizeBody(decoded);
1184
+ const invalid = findInvalidCharacter(normalized);
1185
+ if (invalid !== null) {
1186
+ return {
1187
+ rawLine: raw,
1188
+ decodedBody: null,
1189
+ reason: "invalid_characters",
1190
+ path: configPath,
1191
+ line,
1192
+ detail: `code point ${invalid.position} is a ${invalid.reason}`,
1193
+ };
1194
+ }
1195
+
1196
+ return { rawLine: raw, decodedBody: normalized, reason: "ok", path: configPath, line };
1197
+ }
1198
+
1199
+ /** Import the external value as one custom layer and take ownership of the key. */
1200
+ export function adoptDeveloperInstructions(revision: string, opts?: Paths): WriteResult {
1201
+ const preview = previewAdopt(opts);
1202
+ if (preview.reason !== "ok" || preview.decodedBody === null) {
1203
+ return {
1204
+ ok: false,
1205
+ error: preview.reason === "invalid_characters" ? "invalid_characters" : "developer_instructions_not_owned",
1206
+ detail: preview.detail,
1207
+ };
1208
+ }
1209
+ const body = preview.decodedBody;
1210
+ return commit(opts, revision, (snapshot, configBytes) => {
1211
+ const existing = snapshot.custom;
1212
+ const adopted: CustomLayer = {
1213
+ id: newLayerId(existing),
1214
+ title: "Imported from config.toml",
1215
+ body,
1216
+ enabled: true,
1217
+ };
1218
+ const layers = [adopted, ...existing];
1219
+ // Drop the unowned line first, then write the canonical owned block.
1220
+ const stripped = removeUnownedProjection(configBytes ?? "");
1221
+ return {
1222
+ nextConfig: setProjection(stripped, composeProjection(layers)),
1223
+ nextStore: serializeStore(layers),
1224
+ };
1225
+ });
1226
+ }
1227
+
1228
+ /** Remove an unowned or reshaped `developer_instructions` from the root scope. */
1229
+ function removeUnownedProjection(content: string): string {
1230
+ const eol = dominantEol(content);
1231
+ const lines = splitLines(content);
1232
+ const limit = firstTableIndex(lines);
1233
+ for (let i = 0; i < limit; i += 1) {
1234
+ if (!ANY_DEV_INSTRUCTIONS.test(lines[i]!)) continue;
1235
+ const marked = i > 0 && lines[i - 1]!.includes(OCX_SECTION_MARKER);
1236
+ lines.splice(marked ? i - 1 : i, marked ? 2 : 1);
1237
+ return joinLines(lines, eol);
1238
+ }
1239
+ return joinLines(lines, eol);
1240
+ }
1241
+
1242
+ // ---------------------------------------------------------------------------
1243
+ // Salvage — the store is gone while a live projection remains.
1244
+ //
1245
+ // This is salvage, NOT reconstruction. The projection is one concatenated
1246
+ // string: layer boundaries, ids, titles, order, disabled layers, and whether a
1247
+ // blank line separated two layers or was the user's own text are all gone.
1248
+ // ---------------------------------------------------------------------------
1249
+
1250
+ export interface SalvagePreview {
1251
+ body: string | null;
1252
+ /** the DIRECTORY backups land in. A read-only preview reserves no filename. */
1253
+ backupDir: string;
1254
+ unrecoverable: readonly string[];
1255
+ reason: "ok" | "nothing_to_salvage";
1256
+ }
1257
+
1258
+ const UNRECOVERABLE = Object.freeze([
1259
+ "layer boundaries",
1260
+ "layer ids",
1261
+ "layer titles",
1262
+ "row order",
1263
+ "disabled layers and their bodies",
1264
+ "whether a blank line separated two layers or was your own text",
1265
+ ]);
1266
+
1267
+ export function previewSalvage(opts?: Paths): SalvagePreview {
1268
+ const configPath = activeConfigPath(opts);
1269
+ const storePath = activeStorePath(opts);
1270
+ const ownership = inspectOwnership(readFileOrNull(configPath));
1271
+ const body = ownership.state === "owned" ? decodeBasicString(ownership.literal) : null;
1272
+ return {
1273
+ body,
1274
+ // `dirname`, not a hand-rolled `lastIndexOf("/")`: a Windows store path is
1275
+ // `D:\...\store.toml`, which contains no forward slash at all, so the
1276
+ // slice returned `"."` and the preview named the wrong directory.
1277
+ backupDir: dirname(storePath),
1278
+ unrecoverable: UNRECOVERABLE,
1279
+ reason: body !== null && body.length > 0 ? "ok" : "nothing_to_salvage",
1280
+ };
1281
+ }
1282
+
1283
+ /**
1284
+ * Adopt the live projection as ONE layer. A durable backup is written first and
1285
+ * salvage aborts if it cannot be created — a destructive operation whose safety
1286
+ * net failed should not proceed.
1287
+ */
1288
+ export function salvageProjection(revision: string, opts?: Paths): WriteResult {
1289
+ const preview = previewSalvage(opts);
1290
+ if (preview.reason !== "ok" || preview.body === null) {
1291
+ return { ok: false, error: "developer_instructions_not_owned", detail: "no live projection to salvage" };
1292
+ }
1293
+ const body = preview.body;
1294
+ const storePath = activeStorePath(opts);
1295
+ const backupPath = `${storePath.replace(/\.json$/, "")}.salvage-${Date.now()}-${randomBytes(3).toString("hex")}.txt`;
1296
+ try {
1297
+ durableWriteExclusive(backupPath, body);
1298
+ } catch {
1299
+ return { ok: false, error: "recovery_required", detail: `could not write a durable backup at ${backupPath}` };
1300
+ }
1301
+ return commit(opts, revision, (_snapshot, configBytes) => {
1302
+ const salvaged: CustomLayer = {
1303
+ id: newLayerId([]),
1304
+ title: "Salvaged from config.toml",
1305
+ body,
1306
+ enabled: true,
1307
+ };
1308
+ return {
1309
+ nextConfig: setProjection(configBytes, body),
1310
+ nextStore: serializeStore([salvaged]),
1311
+ };
1312
+ });
1313
+ }