@bitkyc08/opencodex 2.83.0 → 2.86.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 (405) hide show
  1. package/README.md +0 -4
  2. package/bin/ocx.mjs +61 -3
  3. package/gui/dist/assets/App-BjlttVLy.js +58 -0
  4. package/gui/dist/assets/Tray-DyR9yBee.js +1 -0
  5. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  6. package/gui/dist/assets/index-BMssP4k3.js +94 -0
  7. package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +2 -3
  10. package/src/adapters/anthropic/beta-allowlist.ts +87 -8
  11. package/src/adapters/anthropic/client-identity.ts +1 -1
  12. package/src/adapters/anthropic/passthrough.ts +33 -4
  13. package/src/adapters/azure.ts +19 -1
  14. package/src/adapters/client-fingerprint.ts +24 -0
  15. package/src/adapters/coding-agent/protocol.ts +15 -8
  16. package/src/adapters/coding-agent/turn.ts +22 -9
  17. package/src/adapters/cursor/arg-codec.ts +17 -5
  18. package/src/adapters/cursor/catalog.ts +8 -0
  19. package/src/adapters/cursor/effort-map.ts +3 -0
  20. package/src/adapters/cursor/freeform-args.ts +75 -0
  21. package/src/adapters/cursor/protobuf-events.ts +36 -23
  22. package/src/adapters/google-antigravity-wire.ts +22 -1
  23. package/src/adapters/google.ts +2 -2
  24. package/src/adapters/ollama-native.ts +29 -1
  25. package/src/adapters/openai-chat/messages.ts +8 -4
  26. package/src/adapters/openai-chat/response-events.ts +59 -1
  27. package/src/adapters/openai-chat.ts +8 -5
  28. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  29. package/src/adapters/openai-responses/passthrough.ts +3 -0
  30. package/src/adapters/openai-responses/request-strips.ts +10 -2
  31. package/src/adapters/qoder/adapter.ts +56 -15
  32. package/src/advisor/consult.ts +220 -0
  33. package/src/advisor/context.ts +226 -0
  34. package/src/advisor/disclosure.ts +20 -0
  35. package/src/advisor/runtime.ts +305 -0
  36. package/src/advisor/settings.ts +135 -0
  37. package/src/advisor/state.ts +369 -0
  38. package/src/advisor/synthetic-tool.ts +33 -0
  39. package/src/bridge/errors.ts +24 -0
  40. package/src/bridge.ts +1 -1
  41. package/src/chat/inbound.ts +30 -6
  42. package/src/chat/outbound.ts +5 -0
  43. package/src/claude/context-windows.ts +54 -1
  44. package/src/claude/desktop-3p-library.ts +43 -3
  45. package/src/claude/desktop-3p.ts +22 -8
  46. package/src/claude/desktop-picker-profile.ts +11 -4
  47. package/src/claude/desktop-profile.ts +10 -1
  48. package/src/claude/desktop-remote-store-io.ts +5 -3
  49. package/src/claude/inbound.ts +121 -50
  50. package/src/claude/intercept/connect-proxy.ts +10 -3
  51. package/src/claude/intercept/local-ca.ts +8 -1
  52. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  53. package/src/claude/intercept/picker-listener.ts +49 -1
  54. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  55. package/src/claude/intercept/settings.ts +101 -9
  56. package/src/claude/model-default-settings.ts +154 -0
  57. package/src/claude/model-info.ts +7 -7
  58. package/src/claude/outbound.ts +13 -6
  59. package/src/cli/account-api.ts +65 -0
  60. package/src/cli/account-auth.ts +26 -2
  61. package/src/cli/account.ts +41 -5
  62. package/src/cli/advisor.ts +151 -0
  63. package/src/cli/auxiliary-listener-preflight.ts +37 -0
  64. package/src/cli/capabilities-access-remote.ts +8 -0
  65. package/src/cli/capabilities-agents-routing.ts +18 -0
  66. package/src/cli/capabilities-base.ts +14 -2
  67. package/src/cli/catalog-command-result.ts +2 -1
  68. package/src/cli/chatgpt-command.ts +35 -14
  69. package/src/cli/claude.ts +60 -11
  70. package/src/cli/config-command.ts +8 -2
  71. package/src/cli/debug.ts +8 -0
  72. package/src/cli/dispatch.ts +21 -4
  73. package/src/cli/doctor.ts +9 -0
  74. package/src/cli/ensure-desktop-cold-start.ts +329 -0
  75. package/src/cli/export-command.ts +15 -2
  76. package/src/cli/help.ts +2 -1
  77. package/src/cli/hub-disable.ts +99 -0
  78. package/src/cli/hub.ts +4 -2
  79. package/src/cli/index.ts +26 -8
  80. package/src/cli/integration-plan-dto.ts +2 -0
  81. package/src/cli/link-child.ts +104 -0
  82. package/src/cli/link-enrollment.ts +96 -0
  83. package/src/cli/link.ts +39 -24
  84. package/src/cli/observe-stream.ts +6 -1
  85. package/src/cli/observe.ts +10 -2
  86. package/src/cli/provider.ts +12 -0
  87. package/src/cli/registry.ts +20 -7
  88. package/src/cli/restart-scope.ts +3 -1
  89. package/src/cli/runtime-api.ts +27 -2
  90. package/src/cli/status.ts +104 -20
  91. package/src/client/connect.ts +39 -4
  92. package/src/client/hub-client.ts +14 -7
  93. package/src/client/hub-state.ts +25 -14
  94. package/src/client/link-home.ts +248 -0
  95. package/src/client/link-management-relay.ts +61 -0
  96. package/src/client/link-relay.ts +6 -1
  97. package/src/client/link-status.ts +14 -0
  98. package/src/client/machine-api.ts +14 -7
  99. package/src/client/machine-listener.ts +29 -4
  100. package/src/clients/config-export/contracts.ts +1 -0
  101. package/src/clients/config-export/model-metadata.ts +3 -2
  102. package/src/clients/config-export/qoder.ts +93 -0
  103. package/src/clients/config-export.ts +59 -16
  104. package/src/clients/omo-role-models.ts +6 -3
  105. package/src/codex/agent-role-models.ts +26 -8
  106. package/src/codex/auth-api/account-list.ts +17 -2
  107. package/src/codex/auth-api/account-selection.ts +17 -7
  108. package/src/codex/auth-api/pool-mode-gate.ts +5 -2
  109. package/src/codex/auth-api/routes.ts +5 -1
  110. package/src/codex/auth-context.ts +79 -5
  111. package/src/codex/autostart-health.ts +7 -1
  112. package/src/codex/catalog/build-entries.ts +11 -2
  113. package/src/codex/catalog/bundled.ts +5 -1
  114. package/src/codex/catalog/configured-tiers.ts +50 -0
  115. package/src/codex/catalog/effort.ts +6 -3
  116. package/src/codex/catalog/metadata.ts +4 -2
  117. package/src/codex/catalog/model-hints.ts +21 -3
  118. package/src/codex/catalog/native-models.ts +16 -2
  119. package/src/codex/catalog/native-ultrafast.ts +106 -0
  120. package/src/codex/catalog/parsing.ts +5 -0
  121. package/src/codex/catalog/provider-models.ts +4 -0
  122. package/src/codex/catalog/retained-sync.ts +2 -0
  123. package/src/codex/catalog/routed-gather.ts +22 -4
  124. package/src/codex/catalog/vendor-context.ts +82 -0
  125. package/src/codex/config-write-lock.ts +40 -2
  126. package/src/codex/convergence.ts +2 -0
  127. package/src/codex/desktop-app/types.ts +2 -0
  128. package/src/codex/desktop-app/windows.ts +29 -16
  129. package/src/codex/desktop-app-restart.ts +4 -2
  130. package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
  131. package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
  132. package/src/codex/desktop-compatibility/connection-store.ts +87 -0
  133. package/src/codex/desktop-compatibility/installed-build.ts +55 -0
  134. package/src/codex/desktop-compatibility/json-body.ts +29 -0
  135. package/src/codex/desktop-compatibility/native-identity.ts +62 -0
  136. package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
  137. package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
  138. package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
  139. package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
  140. package/src/codex/desktop-compatibility/runtime.ts +173 -0
  141. package/src/codex/desktop-compatibility/service.ts +30 -0
  142. package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
  143. package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
  144. package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
  145. package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
  146. package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
  147. package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
  148. package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
  149. package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
  150. package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
  151. package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
  152. package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
  153. package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
  154. package/src/codex/history-provider.ts +1 -1
  155. package/src/codex/inject/config-write-section.ts +4 -1
  156. package/src/codex/inject/remove.ts +18 -6
  157. package/src/codex/journal.ts +16 -1
  158. package/src/codex/main-account.ts +99 -15
  159. package/src/codex/native-main-refresh-mode.ts +217 -0
  160. package/src/codex/native-profile-startup.ts +4 -1
  161. package/src/codex/prompt-journal.ts +20 -2
  162. package/src/codex/prompt-layers/encoding.ts +49 -0
  163. package/src/codex/prompt-layers/import-source.ts +30 -0
  164. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  165. package/src/codex/prompt-layers/toml-read.ts +52 -4
  166. package/src/codex/prompt-layers.ts +383 -20
  167. package/src/codex/prompt-lock-claim.ts +13 -3
  168. package/src/codex/prompt-lock-evidence.ts +3 -3
  169. package/src/codex/prompt-lock-owner.ts +14 -6
  170. package/src/codex/prompt-text-probe.ts +34 -5
  171. package/src/codex/quota.ts +13 -6
  172. package/src/codex/role-auto-assign.ts +2 -5
  173. package/src/codex/routing/active-account.ts +21 -0
  174. package/src/codex/routing/health-store.ts +1 -1
  175. package/src/codex/routing/pool-recovery.ts +79 -0
  176. package/src/codex/routing.ts +1 -1
  177. package/src/codex/runtime.ts +196 -8
  178. package/src/codex/shim-diagnostics.ts +6 -0
  179. package/src/codex/shim-overlay.ts +15 -6
  180. package/src/codex/shim.ts +1 -1
  181. package/src/combos/failover.ts +3 -0
  182. package/src/config/api-key-reconcile.ts +34 -0
  183. package/src/config/diagnostics.ts +18 -1
  184. package/src/config/feature-flags.ts +5 -0
  185. package/src/config/live-reconcile.ts +56 -29
  186. package/src/config/live-topology.ts +29 -0
  187. package/src/config/load-degrade.ts +8 -2
  188. package/src/config/paths.ts +13 -3
  189. package/src/config/salvage.ts +3 -1
  190. package/src/config/schema/config-schema.ts +19 -0
  191. package/src/config/schema/desktop-compatibility.ts +9 -0
  192. package/src/config/schema/leaf-validators.ts +1 -0
  193. package/src/generated/compatibility-version.json +713 -285
  194. package/src/generated/model-metadata.ts +1 -1
  195. package/src/grok/grpc-web.ts +23 -1
  196. package/src/grok/reset-coupon-ledger.ts +75 -5
  197. package/src/grok/reset-coupons.ts +4 -2
  198. package/src/integrations/catalog-refresh.ts +1 -1
  199. package/src/integrations/config-io.ts +20 -2
  200. package/src/integrations/journal.ts +15 -1
  201. package/src/integrations/mutation-plan.ts +14 -7
  202. package/src/integrations/registry.ts +52 -11
  203. package/src/integrations/state.ts +19 -7
  204. package/src/integrations/store.ts +3 -0
  205. package/src/integrations/target.ts +42 -5
  206. package/src/integrations/writer.ts +24 -7
  207. package/src/lib/advisor-activation.ts +16 -0
  208. package/src/lib/app-owned-memory-stores.ts +4 -0
  209. package/src/lib/app-owned-memory.ts +11 -0
  210. package/src/lib/background-work.ts +65 -0
  211. package/src/lib/bounded-file-read.ts +58 -0
  212. package/src/lib/bun-path-runtime.d.mts +10 -1
  213. package/src/lib/bun-path-runtime.mjs +62 -3
  214. package/src/lib/desktop-proxy-route.ts +30 -0
  215. package/src/lib/desktop-upstream-tunnel.ts +107 -0
  216. package/src/lib/errors.ts +7 -0
  217. package/src/lib/json-byte-size.ts +31 -0
  218. package/src/lib/local-internal-call-capability.ts +55 -0
  219. package/src/lib/optional-shutdown-hooks.ts +35 -0
  220. package/src/lib/outbound-credential-registry.ts +210 -0
  221. package/src/lib/package-tree-integrity.ts +2 -2
  222. package/src/lib/passive-read.ts +35 -0
  223. package/src/lib/shadow-call.ts +44 -5
  224. package/src/lib/socks5-fetch.ts +48 -71
  225. package/src/lib/socks5-handshake.ts +112 -0
  226. package/src/lib/translator-budget.ts +7 -1
  227. package/src/link/codex-settings.ts +155 -0
  228. package/src/link/management-routes.ts +89 -0
  229. package/src/link/remote-probe.ts +38 -0
  230. package/src/link/ssh-argv.ts +26 -5
  231. package/src/link/ssh-runner.ts +58 -27
  232. package/src/link/status-projection.ts +22 -4
  233. package/src/link/supervisor.ts +144 -49
  234. package/src/link/tunnel-state.ts +11 -4
  235. package/src/oauth/anthropic-continuity.ts +4 -3
  236. package/src/oauth/anthropic-identity.ts +13 -2
  237. package/src/oauth/anthropic-model-quota.ts +13 -1
  238. package/src/oauth/anthropic-routing.ts +230 -23
  239. package/src/oauth/anthropic.ts +18 -4
  240. package/src/oauth/generic-account-failover.ts +29 -5
  241. package/src/oauth/google-antigravity.ts +24 -7
  242. package/src/oauth/index.ts +28 -17
  243. package/src/oauth/kiro-account-identity.ts +106 -0
  244. package/src/oauth/kiro-device-login.ts +2 -1
  245. package/src/oauth/kiro.ts +55 -6
  246. package/src/oauth/plan.ts +29 -0
  247. package/src/oauth/store.ts +40 -3
  248. package/src/oauth/types.ts +2 -0
  249. package/src/plugins/loader.ts +11 -0
  250. package/src/protocols/codecs/chat-ir.ts +467 -0
  251. package/src/protocols/codecs/chat.ts +10 -1
  252. package/src/protocols/codecs/messages-ir.ts +500 -0
  253. package/src/protocols/features.ts +10 -2
  254. package/src/protocols/path.ts +11 -1
  255. package/src/protocols/plan-snapshot.ts +39 -16
  256. package/src/protocols/plan.ts +9 -1
  257. package/src/protocols/settings.ts +8 -0
  258. package/src/providers/anthropic-billing-ownership.ts +74 -0
  259. package/src/providers/anthropic-billing-recovery.ts +31 -0
  260. package/src/providers/anthropic-billing-refusal.ts +55 -0
  261. package/src/providers/anthropic-reset-admission.ts +62 -0
  262. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  263. package/src/providers/antigravity-account-roster.ts +102 -0
  264. package/src/providers/antigravity-models.ts +85 -7
  265. package/src/providers/api-key-resolve.ts +17 -2
  266. package/src/providers/key-failover.ts +52 -5
  267. package/src/providers/model-rename-fields.ts +2 -0
  268. package/src/providers/model-rename-migration.ts +5 -3
  269. package/src/providers/model-rename-startup.ts +5 -3
  270. package/src/providers/quota/account-cache.ts +11 -0
  271. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  272. package/src/providers/quota/report-cache.ts +14 -0
  273. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  274. package/src/providers/quota.ts +4 -1
  275. package/src/providers/registry/entries-core.ts +21 -20
  276. package/src/providers/registry/entries-extended.ts +19 -1
  277. package/src/providers/registry/model-seeds.ts +17 -0
  278. package/src/providers/request-pacing.ts +9 -0
  279. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  280. package/src/responses/compaction.ts +14 -0
  281. package/src/responses/parser.ts +37 -20
  282. package/src/responses/state.ts +32 -29
  283. package/src/routing/history/build-file.ts +92 -0
  284. package/src/routing/history/indexer.ts +248 -216
  285. package/src/routing/history/ingest.ts +238 -0
  286. package/src/routing/history/rebuild-job.ts +111 -0
  287. package/src/routing/history/rebuild-protocol.ts +15 -0
  288. package/src/routing/history/rebuild-worker.ts +34 -0
  289. package/src/server/admission-model-scope.ts +4 -1
  290. package/src/server/antigravity-direct-eligibility.ts +208 -0
  291. package/src/server/auth-cors.ts +46 -0
  292. package/src/server/chat-completions.ts +75 -9
  293. package/src/server/claude-messages.ts +110 -20
  294. package/src/server/configured-credentials.ts +194 -0
  295. package/src/server/index/desktop-compatibility-startup.ts +56 -0
  296. package/src/server/index/link-management.ts +58 -0
  297. package/src/server/index/optional-listeners.ts +5 -1
  298. package/src/server/index/serve-options.ts +15 -2
  299. package/src/server/index/startup-warnings.ts +9 -0
  300. package/src/server/index/websocket-handler.ts +9 -0
  301. package/src/server/index.ts +13 -10
  302. package/src/server/inference/client-encoder-delivery.ts +6 -5
  303. package/src/server/lifecycle.ts +2 -1
  304. package/src/server/login-shell-path.ts +146 -0
  305. package/src/server/management/advisor-routes.ts +191 -0
  306. package/src/server/management/agent-settings-routes.ts +50 -14
  307. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  308. package/src/server/management/codex-agent-role-routes.ts +19 -6
  309. package/src/server/management/codex-prompt-routes.ts +136 -5
  310. package/src/server/management/codex-role-auto-assign.ts +1 -1
  311. package/src/server/management/companion-routes.ts +4 -0
  312. package/src/server/management/config-routes.ts +2 -2
  313. package/src/server/management/context.ts +4 -0
  314. package/src/server/management/desktop-compatibility-routes.ts +46 -0
  315. package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
  316. package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
  317. package/src/server/management/grok-coupon-routes.ts +78 -27
  318. package/src/server/management/link-enrollment.ts +192 -0
  319. package/src/server/management/link-route-state.ts +185 -0
  320. package/src/server/management/link-routes.ts +82 -402
  321. package/src/server/management/model-routes.ts +2 -2
  322. package/src/server/management/model-rows.ts +3 -1
  323. package/src/server/management/protocol-settings-patch.ts +1 -0
  324. package/src/server/management/provider-overwrite-carry.ts +10 -0
  325. package/src/server/management/provider-routes.ts +1 -0
  326. package/src/server/management/route-registry.ts +16 -3
  327. package/src/server/management/shared.ts +3 -0
  328. package/src/server/management/sibling-guard.ts +1 -0
  329. package/src/server/management-api.ts +9 -1
  330. package/src/server/messages-antigravity-direct.ts +195 -0
  331. package/src/server/messages-native.ts +73 -43
  332. package/src/server/relay-eager.ts +2 -0
  333. package/src/server/request-log-filter.ts +6 -1
  334. package/src/server/request-log-terminal-status.ts +63 -6
  335. package/src/server/request-log.ts +7 -0
  336. package/src/server/responses/adapter-continuation.ts +35 -18
  337. package/src/server/responses/adapter-delivery.ts +24 -2
  338. package/src/server/responses/adapter-dispatch.ts +261 -81
  339. package/src/server/responses/advisor-plan-slot.ts +27 -0
  340. package/src/server/responses/advisor-slot.ts +336 -0
  341. package/src/server/responses/code-mode-collab-surface.ts +313 -0
  342. package/src/server/responses/codex-ws-exchange.ts +147 -6
  343. package/src/server/responses/codex-ws-wire.ts +203 -0
  344. package/src/server/responses/collaboration.ts +36 -7
  345. package/src/server/responses/compact.ts +126 -48
  346. package/src/server/responses/context-overflow.ts +13 -1
  347. package/src/server/responses/core-codex-account.ts +105 -1
  348. package/src/server/responses/core-combo-failure.ts +24 -1
  349. package/src/server/responses/core-combo.ts +28 -2
  350. package/src/server/responses/core-errors.ts +29 -1
  351. package/src/server/responses/core-lifetime.ts +2 -0
  352. package/src/server/responses/core-normalize.ts +11 -0
  353. package/src/server/responses/core-options.ts +48 -0
  354. package/src/server/responses/core-replay.ts +7 -4
  355. package/src/server/responses/core.ts +1 -1
  356. package/src/server/responses/deferred-collab-surface.ts +158 -0
  357. package/src/server/responses/deferred-send-reporter.ts +25 -0
  358. package/src/server/responses/fetch-helpers.ts +12 -1
  359. package/src/server/responses/passthrough-delivery.ts +20 -11
  360. package/src/server/responses/passthrough-dispatch.ts +29 -7
  361. package/src/server/responses/request-prepare.ts +74 -9
  362. package/src/server/responses/request-transport.ts +22 -8
  363. package/src/server/responses/run-turn-execution.ts +70 -8
  364. package/src/server/responses/sidecar-execution.ts +89 -5
  365. package/src/server/responses/terminal-guard.ts +2 -1
  366. package/src/server/responses/ws-upstream.ts +9 -1
  367. package/src/server/responses-undeclared-tool-guard.ts +30 -9
  368. package/src/server/startup-action-control.ts +38 -4
  369. package/src/server/startup-health-cache.ts +5 -0
  370. package/src/server/system-env-shell.ts +7 -2
  371. package/src/server/system-env.ts +11 -1
  372. package/src/service/cli.ts +5 -1
  373. package/src/service/desktop-startup.ts +161 -34
  374. package/src/service/desktop-supervision.d.mts +27 -1
  375. package/src/service/desktop-supervision.mjs +111 -5
  376. package/src/service/health-sentinel-launch.ts +176 -0
  377. package/src/service/health-sentinel-main.ts +466 -0
  378. package/src/service/health-sentinel-policy.ts +241 -0
  379. package/src/service/health-sentinel-probe.ts +71 -0
  380. package/src/service/health-sentinel-windows.ts +87 -0
  381. package/src/service/linux-linger.ts +20 -0
  382. package/src/service/logging.ts +72 -0
  383. package/src/tray/windows-tray.ps1 +43 -24
  384. package/src/types/config.ts +68 -6
  385. package/src/types/provider.ts +7 -0
  386. package/src/types/request.ts +15 -0
  387. package/src/types/tools.ts +2 -0
  388. package/src/update/async-check.ts +1 -1
  389. package/src/update/badge.ts +2 -2
  390. package/src/update/index.ts +88 -15
  391. package/src/update/job-supervised-restart.ts +107 -0
  392. package/src/update/job.ts +32 -32
  393. package/src/update/notify.ts +1 -1
  394. package/src/update/refresh-scheduler.ts +2 -2
  395. package/src/update/supervised-recovery.d.mts +27 -0
  396. package/src/update/supervised-recovery.mjs +157 -0
  397. package/src/usage/cache-diagnostic.ts +2 -0
  398. package/src/usage/expected-prices.ts +16 -0
  399. package/src/usage/log.ts +19 -1
  400. package/src/web-search/gemini-executor.ts +2 -2
  401. package/assets/sponsors/packycode.png +0 -0
  402. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  403. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  404. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
  405. package/gui/dist/assets/quota-summary-DiJO6sYY.js +0 -1
@@ -34,6 +34,11 @@ export interface PickerListenerOptions {
34
34
  maxEncodedBytes?: number;
35
35
  /** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */
36
36
  maxActiveUpstreams?: number;
37
+ /**
38
+ * Test seam: arms the drop of a connection whose backend ended, after PICKER_ABANDONED_INPUT_LINGER_MS;
39
+ * returns a cancel function. Production uses a timer.
40
+ */
41
+ scheduleAbandonedInputDrop?: (drop: () => void, ms: number) => () => void;
37
42
  log?: (line: string) => void;
38
43
  }
39
44
  export interface PickerListenerHandle { port: number; close(): Promise<void> }
@@ -51,6 +56,12 @@ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256;
51
56
  export const PICKER_MAX_CONCURRENT_STREAMS = 100;
52
57
  /** A connection that has not sent a complete ClientHello by then is dropped. */
53
58
  const CLIENT_HELLO_TIMEOUT_MS = 10_000;
59
+ /**
60
+ * Once the backend has ended a connection and its reply has been flushed, input the client still
61
+ * sends is discarded for at most this long before the connection is dropped. A cooperative client
62
+ * closes as soon as it reads a Connection: close reply; this only bounds one that keeps uploading.
63
+ */
64
+ export const PICKER_ABANDONED_INPUT_LINGER_MS = 30_000;
54
65
 
55
66
  const HOP_HEADERS = new Set([
56
67
  "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization",
@@ -152,6 +163,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
152
163
  const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
153
164
  const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
154
165
  const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS;
166
+ const scheduleAbandonedInputDrop = options.scheduleAbandonedInputDrop ?? ((drop: () => void, ms: number) => {
167
+ const timer = setTimeout(drop, ms);
168
+ return () => clearTimeout(timer);
169
+ });
155
170
  let activeUpstreams = 0;
156
171
  const upgrades = new Set<Duplex>();
157
172
  const spliced = new Set<Socket>();
@@ -205,8 +220,18 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
205
220
  } else req.destroy();
206
221
  }
207
222
  };
223
+ // An HTTP/1.1 reply sent while the upload is unfinished advertises Connection: close. Bun 1.4.3
224
+ // ends such a response with socket.destroySoon() before our finish listeners run, which resets
225
+ // the splice before the reply reaches the client; keep it a graceful FIN that discards input.
226
+ // Only that closing socket is changed, and destroy() stays available for errors and shutdown.
227
+ const closesUnfinishedUpload = () => !h2 && uploading && !req.complete;
228
+ const keepClosingReplyIntact = () => {
229
+ const socket = req.socket;
230
+ socket.destroySoon = () => { req.resume(); socket.end(); return socket; };
231
+ };
208
232
  // Refusals answer with an empty response and a fixed log line that carries no request data.
209
233
  const refuse = (status: 400 | 503) => {
234
+ if (closesUnfinishedUpload()) keepClosingReplyIntact();
210
235
  // Do not leave a refused, still-uploading stream behind after delivering its empty reply.
211
236
  if (uploading && !req.complete) {
212
237
  req.once("error", () => res.destroy());
@@ -236,6 +261,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
236
261
  if (clientGone) return;
237
262
  if (res.headersSent) res.destroy();
238
263
  else {
264
+ if (closesUnfinishedUpload()) keepClosingReplyIntact();
239
265
  res.writeHead(502, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) });
240
266
  res.end(); log(502);
241
267
  }
@@ -260,7 +286,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
260
286
  // HTTP/2 has no reason phrase; its compat writeHead takes the same flat raw header array.
261
287
  if (h2) (relayRes as Http2ServerResponse).writeHead(status, raw as unknown as Record<string, string>);
262
288
  else {
263
- if (uploading && !req.complete) raw.push("Connection", "close");
289
+ if (uploading && !req.complete) {
290
+ keepClosingReplyIntact();
291
+ raw.push("Connection", "close");
292
+ }
264
293
  res.writeHead(status, upRes.statusMessage, raw);
265
294
  }
266
295
  log(status);
@@ -438,6 +467,8 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
438
467
  const offersH2 = clientHelloOffersH2(head);
439
468
  if (offersH2 === null) return;
440
469
  client.off("data", onData);
470
+ // Keep reading an aborted upload after the backend reply FIN.
471
+ client.allowHalfOpen = true;
441
472
  // Paused, a FIN or later bytes wait in the readable buffer and follow `head` once piped.
442
473
  client.pause();
443
474
  clearTimeout(deadline);
@@ -450,6 +481,23 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
450
481
  // its peer (reset, dial failure) tears the peer down instead of leaving it open.
451
482
  inner.once("close", () => { spliced.delete(inner); if (!client.writableEnded) client.destroy(); });
452
483
  client.once("close", () => { if (!inner.writableEnded) inner.destroy(); });
484
+ // The backend reply ended; discard ciphertext still coming from the aborted upload.
485
+ // Unpipe before resuming, so backpressure cannot hide the peer FIN. The discard is bounded
486
+ // from the moment the reply has been flushed to the socket (finish), never before, so the
487
+ // drop cannot cut a reply still being written; finish is not peer receipt, so a peer that
488
+ // also stops reading for the whole budget can lose unread bytes. A peer that keeps sending
489
+ // past the budget is dropped instead of holding the connection until shutdown (#6984).
490
+ inner.once("end", () => {
491
+ client.unpipe(inner);
492
+ client.resume();
493
+ const linger = () => {
494
+ if (client.destroyed) return;
495
+ const cancel = scheduleAbandonedInputDrop(() => client.destroy(), PICKER_ABANDONED_INPUT_LINGER_MS);
496
+ client.once("close", cancel);
497
+ };
498
+ if (client.writableFinished) linger();
499
+ else client.once("finish", linger);
500
+ });
453
501
  inner.once("connect", () => {
454
502
  inner.setNoDelay(true);
455
503
  client.setNoDelay(true);
@@ -0,0 +1,146 @@
1
+ import { readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteFile } from "../../config/atomic-write";
4
+ import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
5
+
6
+ /**
7
+ * The loopback bypass opencodex keeps in Claude Code's settings env (#6936), and the receipt
8
+ * that proves which part of `NO_PROXY` opencodex wrote.
9
+ *
10
+ * `NO_PROXY` is shared with the user, so a value alone cannot say which entries are ours. Apply
11
+ * records `{ hadKey, previous, written }` in `<claudeConfigDir>/opencodex-no-proxy.json` and
12
+ * removal restores `previous` only while `NO_PROXY` still equals `written`; anything else is
13
+ * preserved. The receipt lives exactly as long as the ownership episode that wrote it: an apply
14
+ * that starts a new episode, and every removal, invalidates it first and fails closed when it
15
+ * cannot. It holds only `NO_PROXY` values, never the proxy token.
16
+ */
17
+
18
+ /**
19
+ * Local hosts Claude Code must reach directly, in the order opencodex appends them. The proxy
20
+ * refuses every loopback and unspecified target (connect-proxy.ts isLoopbackTarget); these are the
21
+ * forms a NO_PROXY entry can name. `localhost` also covers `*.localhost` because Claude Code's
22
+ * client suffix-matches entries. Other 127/8 addresses cannot be listed (NO_PROXY has no CIDR) and
23
+ * need their own entry; the proxy's 405 body says so.
24
+ */
25
+ export const CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY = ["127.0.0.1", "localhost", "::1", "0.0.0.0"] as const;
26
+
27
+ export const NO_PROXY_RECEIPT_FILE = "opencodex-no-proxy.json";
28
+
29
+ export interface NoProxyReceipt {
30
+ version: 1;
31
+ /** Whether settings env held a NO_PROXY key before the apply. */
32
+ hadKey: boolean;
33
+ /** The value before the apply (null when the key was absent or held null). */
34
+ previous: string | null;
35
+ /** The value the apply wrote. */
36
+ written: string;
37
+ }
38
+
39
+ /** Canonical index of a loopback bypass entry, or -1. `[::1]` is the bracketed form of `::1`. */
40
+ function loopbackEntryIndex(entry: string): number {
41
+ const normalized = entry.trim().toLowerCase();
42
+ return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.indexOf(
43
+ (normalized === "[::1]" ? "::1" : normalized) as typeof CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY[number],
44
+ );
45
+ }
46
+
47
+ function noProxyEntries(value: string): string[] {
48
+ return value.split(/[,\s]+/).filter(entry => entry.length > 0);
49
+ }
50
+
51
+ /** True when the value already sends every loopback host around the proxy. */
52
+ export function noProxyCoversLoopback(value: unknown): boolean {
53
+ if (typeof value !== "string") return false;
54
+ const entries = noProxyEntries(value);
55
+ if (entries.includes("*")) return true;
56
+ const present = new Set(entries.map(loopbackEntryIndex));
57
+ return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.every((_, index) => present.has(index));
58
+ }
59
+
60
+ /** A NO_PROXY value opencodex can extend: absent, null, or a string. */
61
+ export function noProxyMergeable(value: unknown): boolean {
62
+ return value === undefined || value === null || typeof value === "string";
63
+ }
64
+
65
+ /**
66
+ * The NO_PROXY value apply writes, or null when nothing needs writing: the value already covers
67
+ * loopback, or it is not one opencodex can extend.
68
+ */
69
+ export function mergeClaudeInterceptNoProxy(current: unknown): string | null {
70
+ if (!noProxyMergeable(current) || noProxyCoversLoopback(current)) return null;
71
+ const base = typeof current === "string" ? current : "";
72
+ const present = new Set(noProxyEntries(base).map(loopbackEntryIndex));
73
+ const missing = CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.filter((_, index) => !present.has(index));
74
+ const trimmed = base.replace(/[,\s]+$/, "");
75
+ return trimmed.length === 0 ? missing.join(",") : `${trimmed},${missing.join(",")}`;
76
+ }
77
+
78
+ export function noProxyReceiptPath(configDir: string): string {
79
+ return join(configDir, NO_PROXY_RECEIPT_FILE);
80
+ }
81
+
82
+ interface ReceiptIo {
83
+ write(path: string, content: string): void;
84
+ unlink(path: string): void;
85
+ }
86
+
87
+ const defaultReceiptIo: ReceiptIo = {
88
+ write: (path, content) => atomicWriteFile(path, content),
89
+ unlink: path => unlinkSync(path),
90
+ };
91
+ let receiptIo: ReceiptIo = defaultReceiptIo;
92
+
93
+ /** Replace receipt write/unlink in tests; the real-home guard still runs. Returns a restore. */
94
+ export function setNoProxyReceiptIoForTests(io: Partial<ReceiptIo>): () => void {
95
+ const before = receiptIo;
96
+ receiptIo = { ...defaultReceiptIo, ...io };
97
+ return () => { receiptIo = before; };
98
+ }
99
+
100
+ /** The receipt, or null when it is missing or malformed (both mean: no provenance). */
101
+ export function readNoProxyReceipt(configDir: string): NoProxyReceipt | null {
102
+ let parsed: unknown;
103
+ try {
104
+ parsed = JSON.parse(readFileSync(noProxyReceiptPath(configDir), "utf8"));
105
+ } catch { // no-excuse-ok: catch -- a missing or corrupt receipt is the no-provenance state.
106
+ return null;
107
+ }
108
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
109
+ const receipt = parsed as Record<string, unknown>;
110
+ if (receipt.version !== 1 || typeof receipt.hadKey !== "boolean" || typeof receipt.written !== "string") return null;
111
+ if (receipt.previous !== null && typeof receipt.previous !== "string") return null;
112
+ return { version: 1, hadKey: receipt.hadKey, previous: receipt.previous, written: receipt.written };
113
+ }
114
+
115
+ /** Raw receipt bytes for rollback capture, or null when there is no readable file. */
116
+ export function readNoProxyReceiptBytes(configDir: string): string | null {
117
+ try {
118
+ return readFileSync(noProxyReceiptPath(configDir), "utf8");
119
+ } catch { // no-excuse-ok: catch -- an unreadable receipt is captured as absent.
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** Delete the receipt. Returns false when it exists but could not be removed. */
125
+ export function invalidateNoProxyReceipt(configDir: string): boolean {
126
+ const path = noProxyReceiptPath(configDir);
127
+ // Guard first and outside the catch, so a refusal is visible and nothing is touched.
128
+ assertNotRealClaudeConfigUnderTest(configDir, path);
129
+ try {
130
+ receiptIo.unlink(path);
131
+ return true;
132
+ } catch (error) { // no-excuse-ok: catch -- ENOENT is success; anything else is reported to the caller.
133
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
134
+ }
135
+ }
136
+
137
+ /** Write raw receipt bytes. Throws on failure; callers decide whether that is fatal. */
138
+ export function writeNoProxyReceiptBytes(configDir: string, content: string): void {
139
+ const path = noProxyReceiptPath(configDir);
140
+ assertNotRealClaudeConfigUnderTest(configDir, path);
141
+ receiptIo.write(path, content);
142
+ }
143
+
144
+ export function serializeNoProxyReceipt(receipt: NoProxyReceipt): string {
145
+ return `${JSON.stringify(receipt, null, 2)}\n`;
146
+ }
@@ -4,6 +4,19 @@ import { atomicWriteFile } from "../../config/atomic-write";
4
4
  import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
5
5
  import { claudeConfigDir } from "../auth-detect";
6
6
  import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
7
+ import {
8
+ invalidateNoProxyReceipt,
9
+ mergeClaudeInterceptNoProxy,
10
+ noProxyCoversLoopback,
11
+ noProxyMergeable,
12
+ noProxyReceiptPath,
13
+ readNoProxyReceipt,
14
+ readNoProxyReceiptBytes,
15
+ serializeNoProxyReceipt,
16
+ writeNoProxyReceiptBytes,
17
+ } from "./settings-no-proxy";
18
+
19
+ export { CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY, mergeClaudeInterceptNoProxy } from "./settings-no-proxy";
7
20
 
8
21
  /**
9
22
  * Claude Code `settings.json` env block for intercept mode.
@@ -16,9 +29,21 @@ import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
16
29
  * env.HTTPS_PROXY = http://opencodex:<token>@127.0.0.1:<proxy port>
17
30
  * env.NODE_EXTRA_CA_CERTS = <configDir>/claude-intercept/ca.pem
18
31
  *
32
+ * A third key keeps loopback off that proxy. Claude Code otherwise sends local HTTP MCP traffic
33
+ * (`POST http://127.0.0.1:<port>/mcp`) through it, and the proxy only tunnels CONNECT to
34
+ * non-loopback hosts, so every loopback MCP server failed with a 405 (#6936):
35
+ *
36
+ * env.NO_PROXY = <user entries>,127.0.0.1,localhost,::1,0.0.0.0
37
+ *
19
38
  * Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives
20
39
  * under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names
21
40
  * that file is treated as ours. A user's own proxy or CA setting is left alone.
41
+ *
42
+ * `NO_PROXY` is shared with the user. Apply appends only the missing loopback entries and leaves
43
+ * a covering value (including `*`) alone; removal puts back exactly what was there before, using
44
+ * the receipt described in ./settings-no-proxy.ts, and otherwise preserves the current value.
45
+ * A non-string `NO_PROXY` other than null is not one opencodex can extend: the block stays stale
46
+ * and apply refuses with `foreign_env` instead of rewriting it.
22
47
  */
23
48
 
24
49
  export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const;
@@ -79,6 +104,11 @@ function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string {
79
104
  return typeof value === "string" && value.trim() === ownedCaPath;
80
105
  }
81
106
 
107
+ function setOrDelete(env: Record<string, unknown>, key: string, value: unknown): void {
108
+ if (value === undefined) delete env[key];
109
+ else env[key] = value;
110
+ }
111
+
82
112
  /** Classify the current settings env against the values this router would write. */
83
113
  export function inspectClaudeInterceptSettings(
84
114
  expected: ClaudeInterceptEnv,
@@ -91,7 +121,11 @@ export function inspectClaudeInterceptSettings(
91
121
  const proxy = env.HTTPS_PROXY;
92
122
  const ca = env.NODE_EXTRA_CA_CERTS;
93
123
  if (proxy === undefined && ca === undefined) return { kind: "absent" };
94
- if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS) return { kind: "applied", env: expected };
124
+ // An owned block that still routes loopback into the proxy is stale, so the startup migrate
125
+ // and every reconcile repair installs written before the bypass existed.
126
+ if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS && noProxyCoversLoopback(env.NO_PROXY)) {
127
+ return { kind: "applied", env: expected };
128
+ }
95
129
  const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS);
96
130
  const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy);
97
131
  if (caOurs && proxyOurs) {
@@ -112,13 +146,28 @@ export function inspectClaudeInterceptSettings(
112
146
  };
113
147
  }
114
148
 
115
- function writeSettings(path: string, doc: SettingsDoc): void {
149
+ const defaultPersist = (path: string, content: string): void => atomicWriteFile(path, content);
150
+ let persistFile = defaultPersist;
151
+
152
+ /** Replace the settings.json write in tests; the real-home guard still runs first. Returns a restore. */
153
+ export function setClaudeSettingsPersistForTests(persist: (path: string, content: string) => void): () => void {
154
+ const before = persistFile;
155
+ persistFile = persist;
156
+ return () => { persistFile = before; };
157
+ }
158
+
159
+ /** Commit settings.json. Once this returns, the file on disk holds the new document. */
160
+ function persistSettings(path: string, doc: SettingsDoc): void {
116
161
  // An armed test process must not rewrite the real Claude settings.json (#6775).
117
162
  assertNotRealClaudeConfigUnderTest(dirname(path), path);
118
163
  mkdirSync(dirname(path), { recursive: true });
119
164
  // The managed env embeds the proxy token, so the file must stay owner-only:
120
165
  // atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op.
121
- atomicWriteFile(path, `${JSON.stringify(doc, null, 2)}\n`);
166
+ persistFile(path, `${JSON.stringify(doc, null, 2)}\n`);
167
+ }
168
+
169
+ function writeSettings(path: string, doc: SettingsDoc): void {
170
+ persistSettings(path, doc);
122
171
  // Whether Claude Code reaches the proxy just changed, so its cached /model list is stale.
123
172
  invalidateClaudeCodeServedCatalog(dirname(path));
124
173
  }
@@ -137,20 +186,31 @@ export function captureClaudeInterceptSettingsRollback(
137
186
  const before = readSettings(path);
138
187
  if ("error" in before && before.error !== "missing") return () => false;
139
188
  const previous = "doc" in before ? { ...envRecord(before.doc) } : {};
189
+ const previousReceipt = readNoProxyReceiptBytes(configDir);
190
+ // What apply writes over the captured value; rollback restores NO_PROXY only while it still
191
+ // holds exactly that, so a user edit made after the apply survives. The receipt is restored
192
+ // with it, because it describes that value.
193
+ const appliedNoProxy = mergeClaudeInterceptNoProxy(previous.NO_PROXY) ?? previous.NO_PROXY;
140
194
  return () => {
141
195
  try {
196
+ assertNotRealClaudeConfigUnderTest(configDir, path, noProxyReceiptPath(configDir));
142
197
  const target = typeof expected === "function" ? expected() : expected;
143
198
  const current = readSettings(path);
144
199
  if (!("doc" in current)) return false;
145
200
  const env = envRecord(current.doc);
146
201
  if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== target[key])) return false;
147
202
  for (const key of CLAUDE_INTERCEPT_MANAGED_ENV) {
148
- if (previous[key] === undefined) delete env[key];
149
- else env[key] = previous[key];
203
+ setOrDelete(env, key, previous[key]);
150
204
  }
205
+ const restoreNoProxy = env.NO_PROXY === appliedNoProxy;
206
+ if (restoreNoProxy) setOrDelete(env, "NO_PROXY", previous.NO_PROXY);
151
207
  if (Object.keys(env).length === 0) delete current.doc.env;
152
208
  else current.doc.env = env;
153
209
  writeSettings(path, current.doc);
210
+ if (restoreNoProxy) {
211
+ if (previousReceipt === null) return invalidateNoProxyReceipt(configDir);
212
+ writeNoProxyReceiptBytes(configDir, previousReceipt);
213
+ }
154
214
  return true;
155
215
  } catch { return false; }
156
216
  };
@@ -158,7 +218,8 @@ export function captureClaudeInterceptSettingsRollback(
158
218
 
159
219
  /**
160
220
  * Write the intercept env into `settings.json`. Refuses when a managed key already holds a
161
- * value opencodex did not write (a user-configured corporate proxy, for instance).
221
+ * value opencodex did not write (a user-configured corporate proxy, for instance), or when
222
+ * NO_PROXY holds a non-string value opencodex cannot extend.
162
223
  */
163
224
  export function applyClaudeInterceptSettings(
164
225
  env: ClaudeInterceptEnv,
@@ -171,8 +232,21 @@ export function applyClaudeInterceptSettings(
171
232
  if (state.kind === "applied") return { ok: true, changed: false, path };
172
233
  const read = readSettings(path);
173
234
  const doc: SettingsDoc = "doc" in read ? read.doc : {};
174
- doc.env = { ...envRecord(doc), ...env };
235
+ const current = envRecord(doc);
236
+ if (!noProxyMergeable(current.NO_PROXY)) return { ok: false, reason: "foreign_env", path };
237
+ const noProxy = mergeClaudeInterceptNoProxy(current.NO_PROXY);
238
+ // A new ownership episode, or a new NO_PROXY value, makes any existing receipt describe the
239
+ // wrong value. Drop it before writing, and write nothing when it cannot be dropped.
240
+ if ((state.kind === "absent" || noProxy !== null) && !invalidateNoProxyReceipt(configDir)) {
241
+ return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
242
+ }
243
+ doc.env = { ...current, ...env, ...(noProxy === null ? {} : { NO_PROXY: noProxy }) };
175
244
  writeSettings(path, doc);
245
+ if (noProxy !== null) {
246
+ const receipt = { version: 1 as const, hadKey: "NO_PROXY" in current, previous: typeof current.NO_PROXY === "string" ? current.NO_PROXY : null, written: noProxy };
247
+ // Without a receipt, removal keeps the appended entries: the harmless direction.
248
+ try { writeNoProxyReceiptBytes(configDir, serializeNoProxyReceipt(receipt)); } catch { /* no provenance */ }
249
+ }
176
250
  return { ok: true, changed: true, path };
177
251
  }
178
252
 
@@ -195,7 +269,11 @@ export function migrateClaudeInterceptSettings(
195
269
  return applyClaudeInterceptSettings(env, configDir);
196
270
  }
197
271
 
198
- /** Remove the managed keys, but only the values opencodex owns. */
272
+ /**
273
+ * Remove the managed keys and put NO_PROXY back as it was, but only the values opencodex owns.
274
+ * The receipt is dropped before the settings write; when that fails nothing is changed, so a
275
+ * receipt can never outlive the episode it describes.
276
+ */
199
277
  export function removeClaudeInterceptSettings(
200
278
  ownedCaPath: string,
201
279
  configDir = claudeConfigDir(),
@@ -207,10 +285,24 @@ export function removeClaudeInterceptSettings(
207
285
  }
208
286
  const env = envRecord(read.doc);
209
287
  if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path };
288
+ const receipt = readNoProxyReceipt(configDir);
289
+ const receiptBytes = readNoProxyReceiptBytes(configDir);
210
290
  delete env.NODE_EXTRA_CA_CERTS;
211
291
  if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY;
292
+ if (receipt && env.NO_PROXY === receipt.written) setOrDelete(env, "NO_PROXY", receipt.hadKey ? receipt.previous : undefined);
293
+ if (!invalidateNoProxyReceipt(configDir)) return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
212
294
  if (Object.keys(env).length === 0) delete read.doc.env;
213
295
  else read.doc.env = env;
214
- writeSettings(path, read.doc);
296
+ try {
297
+ persistSettings(path, read.doc);
298
+ } catch (error) {
299
+ // A removal that was not committed has not ended the episode, so its receipt stays valid. Only the
300
+ // persist step is covered: once settings.json changed, the receipt must not come back.
301
+ if (receiptBytes !== null) {
302
+ try { writeNoProxyReceiptBytes(configDir, receiptBytes); } catch { /* keep the original error */ }
303
+ }
304
+ throw error;
305
+ }
306
+ invalidateClaudeCodeServedCatalog(dirname(path));
215
307
  return { ok: true, changed: true, path };
216
308
  }
@@ -0,0 +1,154 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, unlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { atomicWriteFileNoFollow } from "../config/atomic-write";
5
+ import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
6
+ import { assertNotRealClaudeConfigUnderTest } from "../lib/test-home-guard";
7
+ import { claudeConfigDir } from "./auth-detect";
8
+ import { claudeTierDisplayName, type ClaudeTierModels } from "./context-windows";
9
+
10
+ const tiers = ["opus", "sonnet", "haiku", "fable"] as const;
11
+
12
+ function readSettings(path: string): { raw: string; exists: boolean; doc: Record<string, unknown>; env: Record<string, unknown> } {
13
+ let raw = "";
14
+ let exists = false;
15
+ try {
16
+ const stat = lstatSync(path);
17
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw new Error("settings_unreadable");
18
+ raw = readFileSync(path, "utf8");
19
+ exists = true;
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("settings_unreadable");
22
+ }
23
+ let doc: Record<string, unknown>;
24
+ try {
25
+ const parsed: unknown = exists ? JSON.parse(raw) : {};
26
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
27
+ doc = parsed as Record<string, unknown>;
28
+ if (doc.env !== undefined && (!doc.env || typeof doc.env !== "object" || Array.isArray(doc.env))) throw new Error();
29
+ } catch { throw new Error("settings_unreadable"); }
30
+ return { raw, exists, doc, env: (doc.env ?? {}) as Record<string, unknown> };
31
+ }
32
+
33
+ function snapshotFingerprint(current: ReturnType<typeof readSettings>, models: ClaudeTierModels | undefined) {
34
+ const target = selectedEnv(models, current.env);
35
+ return createHash("sha256").update(JSON.stringify([current.exists, current.raw, target])).digest("hex");
36
+ }
37
+
38
+ /** Test seams for edits at publication boundaries; never configured through the API. */
39
+ export interface ModelDefaultWriteHooks {
40
+ beforeCapture?: () => void;
41
+ beforePublish?: () => void;
42
+ }
43
+
44
+ export class ModelDefaultsRecoveryError extends Error {
45
+ constructor(readonly backupPath: string, options?: ErrorOptions) {
46
+ super("settings_recovery_required", options);
47
+ }
48
+ }
49
+
50
+ /** Unset tiers stay untouched; only a label matching its current alias is managed. */
51
+ function selectedEnv(models: ClaudeTierModels | undefined, current: Record<string, unknown>): Record<string, string | null> {
52
+ const env: Record<string, string | null> = {};
53
+ for (const tier of tiers) {
54
+ const slot = `ANTHROPIC_DEFAULT_${tier.toUpperCase()}_MODEL`;
55
+ const name = `${slot}_NAME`;
56
+ const value = models?.[tier]?.trim();
57
+ if (!value) continue;
58
+ env[slot] = value;
59
+ const previous = typeof current[slot] === "string" ? claudeTierDisplayName(current[slot]) : undefined;
60
+ const managed = previous !== undefined && current[name] === previous;
61
+ // Custom (including empty/non-string) labels are user-owned. Exact pairing
62
+ // follows the launcher label convention; the name alone proves no ownership.
63
+ if (!Object.hasOwn(current, name) || managed) {
64
+ const label = claudeTierDisplayName(value);
65
+ if (label !== undefined) env[name] = label;
66
+ else if (managed) env[name] = null;
67
+ }
68
+ }
69
+ return env;
70
+ }
71
+
72
+ export function previewClaudeModelDefaults(models: ClaudeTierModels | undefined, configDir = claudeConfigDir()) {
73
+ const path = join(configDir, "settings.json");
74
+ const current = readSettings(path);
75
+ const target = selectedEnv(models, current.env);
76
+ const changes = Object.entries(target).filter(([key, value]) => current.env[key] !== value)
77
+ // The existing preview wire shape uses an empty replacement for label removal.
78
+ .map(([key, value]) => ({ key, before: typeof current.env[key] === "string" ? current.env[key] as string : null, after: value ?? "" }));
79
+ const fingerprint = snapshotFingerprint(current, models);
80
+ return { path, fingerprint, changes };
81
+ }
82
+
83
+ export function applyClaudeModelDefaults(
84
+ models: ClaudeTierModels | undefined,
85
+ fingerprint: string,
86
+ configDir = claudeConfigDir(),
87
+ backupDir = join(configDir, "model-default-backups"),
88
+ hooks: ModelDefaultWriteHooks = {},
89
+ ) {
90
+ const preview = previewClaudeModelDefaults(models, configDir);
91
+ if (preview.fingerprint !== fingerprint) throw new Error("settings_changed");
92
+ if (!preview.changes.length) return { changed: false, path: preview.path };
93
+ assertNotRealClaudeConfigUnderTest(configDir, preview.path);
94
+ const current = readSettings(preview.path);
95
+ const check = snapshotFingerprint(current, models);
96
+ if (check !== fingerprint) throw new Error("settings_changed");
97
+ mkdirSync(configDir, { recursive: true });
98
+ mkdirSync(backupDir, { recursive: true, mode: 0o700 });
99
+ const recoveryDir = mkdtempSync(join(backupDir, "apply-"));
100
+ if (process.platform === "win32") hardenSecretDir(recoveryDir, { required: true });
101
+ const backupPath = join(recoveryDir, "settings.json");
102
+ const staged = join(configDir, `.model-defaults-${randomUUID()}.tmp`);
103
+ let captured = false;
104
+ let published = false;
105
+ try {
106
+ const env = { ...current.env };
107
+ for (const [key, value] of Object.entries(selectedEnv(models, current.env))) {
108
+ if (value === null) delete env[key];
109
+ else env[key] = value;
110
+ }
111
+ current.doc.env = env;
112
+ atomicWriteFileNoFollow(staged, `${JSON.stringify(current.doc, null, 2)}\n`);
113
+ // Verify same-volume exclusive publication before moving the shared file.
114
+ const probe = join(recoveryDir, "link-probe");
115
+ linkSync(staged, probe);
116
+ unlinkSync(probe);
117
+ hooks.beforeCapture?.();
118
+ if (current.exists) {
119
+ // Move the actual destination, not a stale in-memory copy. A same-filesystem
120
+ // move captures late edits; cross-device failures leave the original alone.
121
+ try { renameSync(preview.path, backupPath); } catch (error) {
122
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("settings_changed");
123
+ throw error;
124
+ }
125
+ captured = true;
126
+ const capturedEntry = lstatSync(backupPath);
127
+ if (!capturedEntry.isFile() || capturedEntry.isSymbolicLink()) throw new Error("settings_changed");
128
+ if (process.platform === "win32") hardenSecretPath(backupPath, { required: true });
129
+ if (snapshotFingerprint(readSettings(backupPath), models) !== fingerprint) throw new Error("settings_changed");
130
+ }
131
+ hooks.beforePublish?.();
132
+ // A hard-link publish is exclusive on POSIX and Windows: never replace a
133
+ // document created by another writer during the brief move-aside interval.
134
+ try { linkSync(staged, preview.path); } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("settings_changed");
136
+ throw error;
137
+ }
138
+ published = true;
139
+ return { changed: true, path: preview.path, ...(captured ? { backupPath } : {}) };
140
+ } catch (error) {
141
+ if (captured && !published) {
142
+ try { linkSync(backupPath, preview.path); } catch (restoreError) {
143
+ // A newer destination wins. Retain the captured document privately;
144
+ // never delete the winner or overwrite it as an automatic rollback.
145
+ throw new ModelDefaultsRecoveryError(backupPath, { cause: restoreError });
146
+ }
147
+ }
148
+ throw error;
149
+ } finally {
150
+ // The private stage is also present at the destination after successful
151
+ // linking; cleanup failure must not turn a committed apply into failure.
152
+ try { unlinkSync(staged); } catch { /* private residue, no shared-file cleanup */ }
153
+ }
154
+ }
@@ -103,19 +103,19 @@ function modelInfo(id: string, displayName: string, ladder: readonly string[], i
103
103
 
104
104
  /**
105
105
  * Which id family the discovery list carries (devlog 050): Claude Code (CLI)
106
- * gets readable `claude-ocx-*` ids; Claude Desktop keeps the hashed
107
- * `claude-opus-4-8-<code>` family its 3P config was written with. Both families
106
+ * gets readable `ocx-claude-*` ids; Claude Desktop keeps its profile's
107
+ * `claude-fable-5-p<code>` ids (legacy no-profile callers use hash aliases). Both families
108
108
  * decode in resolveInboundModel regardless of the style served here.
109
109
  */
110
110
  export type AnthropicIdStyle = "desktop3p" | "readable";
111
111
 
112
- /** Build the full anthropic-flavor discovery list (ids are Desktop 3P aliases). */
112
+ /** Build discovery rows; a Desktop resolver can return undefined to withhold an unbound ID. */
113
113
  export function buildAnthropicModelInfos(
114
114
  nativeSlugs: readonly string[],
115
115
  routedModels: readonly CatalogModel[],
116
116
  auto: AutoContextMode = AUTO_CONTEXT_OFF,
117
117
  idStyle: AnthropicIdStyle = "desktop3p",
118
- aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias,
118
+ aliasForRoute: (provider: string, modelId: string) => string | undefined = desktop3pAlias,
119
119
  nativeContextCap?: NativeContextLimitsInput,
120
120
  fastMode?: boolean,
121
121
  // Presence is the feature gate: the caller passes undefined when `fastRows` is off, so a
@@ -144,7 +144,7 @@ export function buildAnthropicModelInfos(
144
144
  ? claudeCodeAlias(m.provider, listed)
145
145
  : aliasForRoute(m.provider, m.id);
146
146
  }),
147
- ]);
147
+ ].filter((id): id is string => typeof id === "string" && id.length > 0));
148
148
  // [1m] picker variant (devlog 260712 B1; widened in devlog/_plan/261009_claude_1m_default/020):
149
149
  // Claude Code accounts exactly 1M for ids carrying the marker (2.1.207 binary: /\[1m\]/i -> 1e6,
150
150
  // compaction preserved). A discovery client may lack CLAUDE_CODE_AUTO_COMPACT_WINDOW, so the
@@ -201,7 +201,7 @@ export function buildAnthropicModelInfos(
201
201
  };
202
202
  for (const slug of nativeSlugs) {
203
203
  const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : aliasForRoute("native", slug);
204
- if (seen.has(id)) continue;
204
+ if (!id || seen.has(id)) continue;
205
205
  seen.add(id);
206
206
  const nativeWindow = nativeOpenAiContextWindow(slug, nativeContextCap);
207
207
  const nativeMaxInput = nativeOpenAiMaxInputTokens(slug, nativeContextCap);
@@ -228,7 +228,7 @@ export function buildAnthropicModelInfos(
228
228
  const id = idStyle === "readable"
229
229
  ? claudeCodeAlias(m.provider, listedModelId)
230
230
  : aliasForRoute(m.provider, m.id);
231
- if (seen.has(id)) continue;
231
+ if (!id || seen.has(id)) continue;
232
232
  seen.add(id);
233
233
  const groupStart = out.length;
234
234
  const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : [];