@bitkyc08/opencodex 2.86.0 → 2.87.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 (378) hide show
  1. package/gui/dist/assets/App-ZfYbLHkB.js +58 -0
  2. package/gui/dist/assets/App-wT3mXqFz.css +1 -0
  3. package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
  4. package/gui/dist/assets/index-CfYDMj34.js +94 -0
  5. package/gui/dist/assets/index-DPeB9HpV.css +1 -0
  6. package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic/beta-allowlist.ts +9 -8
  10. package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -23
  12. package/src/adapters/anthropic-progress.ts +84 -0
  13. package/src/adapters/anthropic.ts +108 -21
  14. package/src/adapters/base.ts +2 -0
  15. package/src/adapters/claude-cli/adapter.ts +111 -23
  16. package/src/adapters/claude-cli/profiles.ts +2 -1
  17. package/src/adapters/claude-cli/stable-replay.ts +108 -0
  18. package/src/adapters/claude-cli/usage-admission.ts +230 -0
  19. package/src/adapters/coding-agent/protocol.ts +1 -1
  20. package/src/adapters/coding-agent/turn.ts +18 -2
  21. package/src/adapters/dsh-account.ts +97 -0
  22. package/src/adapters/google.ts +35 -8
  23. package/src/adapters/openai-chat/messages.ts +4 -3
  24. package/src/adapters/openai-chat/passthrough.ts +8 -2
  25. package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
  26. package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
  27. package/src/adapters/openai-responses/passthrough.ts +57 -125
  28. package/src/adapters/openai-responses/request-strips.ts +25 -3
  29. package/src/adapters/openai-responses/transport.ts +81 -0
  30. package/src/adapters/qoder/adapter.ts +4 -2
  31. package/src/adapters/registry.ts +5 -0
  32. package/src/adapters/zed.ts +26 -1
  33. package/src/advisor/activity.ts +55 -0
  34. package/src/advisor/budget.ts +62 -0
  35. package/src/advisor/consult.ts +4 -1
  36. package/src/advisor/context.ts +16 -4
  37. package/src/advisor/evidence-tool.ts +21 -0
  38. package/src/advisor/evidence.ts +117 -0
  39. package/src/advisor/runtime.ts +79 -31
  40. package/src/advisor/settings.ts +11 -2
  41. package/src/bridge/errors.ts +19 -1
  42. package/src/bridge/internal.ts +3 -1
  43. package/src/bridge/response-json.ts +18 -3
  44. package/src/bridge/sse.ts +58 -8
  45. package/src/bridge/terminal-refusal.ts +59 -0
  46. package/src/claude/desktop-3p-library.ts +32 -7
  47. package/src/claude/desktop-3p.ts +55 -13
  48. package/src/claude/desktop-first-party.ts +1 -1
  49. package/src/claude/desktop-picker-platform.ts +4 -0
  50. package/src/claude/desktop-picker.ts +15 -8
  51. package/src/claude/desktop-system-proxy.ts +50 -4
  52. package/src/claude/inbound-content-options.ts +11 -0
  53. package/src/claude/inbound.ts +33 -0
  54. package/src/claude/intercept/picker-ca-startup.ts +2 -1
  55. package/src/claude/intercept/picker-runtime.ts +6 -5
  56. package/src/claude/intercept/picker-trust-windows.ts +338 -0
  57. package/src/claude/intercept/picker-trust.ts +19 -0
  58. package/src/claude/intercept/port-policy.ts +30 -0
  59. package/src/claude/intercept/runtime.ts +97 -94
  60. package/src/claude/outbound.ts +38 -13
  61. package/src/cli/account-api.ts +1 -0
  62. package/src/cli/account-policy-dto.ts +6 -0
  63. package/src/cli/account-policy.ts +34 -5
  64. package/src/cli/account-preservation.ts +44 -0
  65. package/src/cli/account.ts +8 -2
  66. package/src/cli/advisor.ts +17 -3
  67. package/src/cli/capabilities-accounts.ts +20 -1
  68. package/src/cli/capabilities-agents-routing.ts +5 -2
  69. package/src/cli/capabilities-base.ts +38 -10
  70. package/src/cli/capabilities-observe-system.ts +63 -3
  71. package/src/cli/capabilities-provider-models.ts +11 -0
  72. package/src/cli/codex-cli-update.ts +151 -41
  73. package/src/cli/codex-provider.ts +44 -0
  74. package/src/cli/combo-input.ts +10 -1
  75. package/src/cli/combo-result.ts +1 -1
  76. package/src/cli/combo.ts +2 -1
  77. package/src/cli/cross-home-owner.ts +17 -14
  78. package/src/cli/dispatch.ts +25 -4
  79. package/src/cli/doctor.ts +6 -1
  80. package/src/cli/ensure-desktop-cold-start.ts +60 -7
  81. package/src/cli/help.ts +3 -2
  82. package/src/cli/index.ts +1 -1
  83. package/src/cli/integration-plan-dto.ts +1 -0
  84. package/src/cli/log-history.ts +172 -0
  85. package/src/cli/models-runtime-subcommands.ts +1 -0
  86. package/src/cli/models-runtime.ts +72 -8
  87. package/src/cli/observe-stream.ts +2 -0
  88. package/src/cli/observe.ts +16 -4
  89. package/src/cli/provider.ts +27 -5
  90. package/src/cli/registry.ts +28 -8
  91. package/src/cli/runtime-api.ts +5 -3
  92. package/src/cli/system-command.ts +40 -8
  93. package/src/cli/uninstall-client-state.ts +32 -0
  94. package/src/cli/usage-compare.ts +309 -0
  95. package/src/cli/usage-model-search.ts +4 -4
  96. package/src/cli/usage-report.ts +2 -2
  97. package/src/cli/v2.ts +1 -1
  98. package/src/client/machine-listener.ts +1 -3
  99. package/src/client/runtime.ts +1 -0
  100. package/src/clients/client-path.ts +51 -0
  101. package/src/clients/config-export/contracts.ts +2 -1
  102. package/src/clients/config-export.ts +18 -44
  103. package/src/clients/gentle-shell-path.ts +87 -0
  104. package/src/codex/account-auto-switch.ts +2 -0
  105. package/src/codex/account-identity.ts +29 -0
  106. package/src/codex/account-label.ts +1 -1
  107. package/src/codex/account-lifecycle.ts +21 -5
  108. package/src/codex/account-preservation.ts +28 -0
  109. package/src/codex/account-use-remaining.ts +23 -0
  110. package/src/codex/app-server-processes.ts +97 -7
  111. package/src/codex/app-server-restart-service.ts +55 -4
  112. package/src/codex/auth-api/account-list.ts +4 -0
  113. package/src/codex/auth-api/account-pause-group.ts +6 -50
  114. package/src/codex/auth-api/account-preservation.ts +57 -0
  115. package/src/codex/auth-api/routes.ts +8 -0
  116. package/src/codex/auth-api/use-remaining-route.ts +36 -0
  117. package/src/codex/auth-api.ts +0 -1
  118. package/src/codex/auth-context.ts +2 -2
  119. package/src/codex/catalog/auto-review.ts +20 -3
  120. package/src/codex/catalog/build-entries.ts +9 -1
  121. package/src/codex/catalog/combo-member.ts +14 -5
  122. package/src/codex/catalog/configured-tiers.ts +1 -1
  123. package/src/codex/catalog/gather-capture.ts +2 -0
  124. package/src/codex/catalog/metadata.ts +11 -9
  125. package/src/codex/catalog/model-hints.ts +21 -16
  126. package/src/codex/catalog/model-visibility.ts +3 -1
  127. package/src/codex/catalog/parsing.ts +30 -86
  128. package/src/codex/catalog/provider-models.ts +36 -34
  129. package/src/codex/catalog/routed-gather.ts +12 -13
  130. package/src/codex/cli-install-provenance.ts +105 -17
  131. package/src/codex/cli-update-plan.ts +545 -0
  132. package/src/codex/config-write-lock.ts +23 -2
  133. package/src/codex/desktop-app/picker-freshness.ts +96 -0
  134. package/src/codex/desktop-app-restart.ts +21 -0
  135. package/src/codex/desktop-compatibility/installed-build.ts +24 -6
  136. package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
  137. package/src/codex/desktop-compatibility/runtime.ts +12 -3
  138. package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
  139. package/src/codex/features.ts +85 -45
  140. package/src/codex/inject/history-failure.ts +27 -0
  141. package/src/codex/inject/restore.ts +24 -2
  142. package/src/codex/inject/routing-classify.ts +4 -3
  143. package/src/codex/inject.ts +18 -27
  144. package/src/codex/main-account-hard-lock.ts +30 -1
  145. package/src/codex/native-profile-startup.ts +1 -1
  146. package/src/codex/ocx-compaction-history.ts +12 -10
  147. package/src/codex/paths.ts +86 -1
  148. package/src/codex/provider-routing.ts +174 -0
  149. package/src/codex/refresh.ts +2 -2
  150. package/src/codex/routing/health-store.ts +1 -1
  151. package/src/codex/routing/selection.ts +4 -3
  152. package/src/codex/routing/thread-affinity.ts +1 -1
  153. package/src/codex/signed-in-identity.ts +43 -0
  154. package/src/codex/subagent-model-fallback.ts +1 -1
  155. package/src/codex/sync.ts +6 -1
  156. package/src/codex/user-identity.ts +16 -5
  157. package/src/codex/windows-local-appdata.ts +84 -0
  158. package/src/combos/failover.ts +6 -1
  159. package/src/combos/index.ts +1 -0
  160. package/src/combos/resolve.ts +46 -10
  161. package/src/combos/types.ts +15 -0
  162. package/src/config/diagnostics.ts +33 -6
  163. package/src/config/live-reconcile.ts +38 -2
  164. package/src/config/load-degrade.ts +17 -0
  165. package/src/config/opencode-wire-upgrade.ts +54 -0
  166. package/src/config/opencode-zen-upgrade.ts +52 -0
  167. package/src/config/owner-registry.ts +22 -17
  168. package/src/config/provider-validation.ts +45 -2
  169. package/src/config/schema/config-schema.ts +22 -0
  170. package/src/config/schema/leaf-validators.ts +38 -9
  171. package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
  172. package/src/config/schema/native-reasoning-retention.ts +26 -0
  173. package/src/config.ts +2 -0
  174. package/src/generated/compatibility-version.json +608 -284
  175. package/src/grok/sync.ts +1 -1
  176. package/src/images/loop.ts +46 -8
  177. package/src/integrations/catalog-refresh.ts +1 -1
  178. package/src/integrations/mutation-plan.ts +1 -0
  179. package/src/integrations/registry.ts +8 -0
  180. package/src/lib/codex-restart-contract.ts +15 -0
  181. package/src/lib/desktop-upstream-tunnel.ts +18 -53
  182. package/src/lib/empty-tool-catalog.ts +62 -0
  183. package/src/lib/errors.ts +19 -3
  184. package/src/lib/gcp-adc.ts +96 -9
  185. package/src/lib/gcp-credential-input.ts +12 -0
  186. package/src/lib/lab-live-route-production.ts +5 -0
  187. package/src/lib/opaque-proxy-tunnel.ts +217 -0
  188. package/src/lib/redact.ts +2 -2
  189. package/src/oauth/account-quota-rank.ts +69 -2
  190. package/src/oauth/anthropic-account-refusal.ts +20 -2
  191. package/src/oauth/anthropic-oauth-definitions.ts +3 -1
  192. package/src/oauth/anthropic-routing.ts +1 -21
  193. package/src/oauth/anthropic.ts +38 -7
  194. package/src/oauth/callback-server.ts +27 -1
  195. package/src/oauth/chatgpt-device.ts +1 -1
  196. package/src/oauth/chatgpt-shared.ts +83 -0
  197. package/src/oauth/chatgpt.ts +8 -72
  198. package/src/oauth/dsh.ts +203 -0
  199. package/src/oauth/generic-account-failover.ts +33 -7
  200. package/src/oauth/generic-session-affinity.ts +125 -0
  201. package/src/oauth/index.ts +184 -109
  202. package/src/oauth/local-token-detect.ts +21 -1
  203. package/src/oauth/login-cli-errors.ts +44 -0
  204. package/src/oauth/login-cli.ts +69 -7
  205. package/src/oauth/login-flow-state.ts +371 -23
  206. package/src/oauth/meta-muse-errors.ts +41 -0
  207. package/src/oauth/meta-muse-keychain.ts +52 -0
  208. package/src/oauth/meta-muse.ts +51 -84
  209. package/src/oauth/model-discovery-auth.ts +3 -0
  210. package/src/oauth/pool-settings-capability.ts +4 -2
  211. package/src/oauth/refresh-flight-retirement.ts +48 -0
  212. package/src/oauth/session-affinity-key.ts +23 -0
  213. package/src/oauth/store-anthropic-instance.ts +9 -2
  214. package/src/oauth/store.ts +12 -5
  215. package/src/protocols/encoders/adapter-events.ts +5 -4
  216. package/src/protocols/encoders/messages.ts +9 -6
  217. package/src/protocols/provider-summary.ts +2 -1
  218. package/src/providers/api-key-cooldowns.ts +49 -0
  219. package/src/providers/api-key-resolve.ts +11 -6
  220. package/src/providers/api-key-response-ownership.ts +57 -0
  221. package/src/providers/api-key-selection.ts +31 -6
  222. package/src/providers/api-keys.ts +214 -3
  223. package/src/providers/claude-cli-usage.ts +96 -0
  224. package/src/providers/dsh-account-destination.ts +31 -0
  225. package/src/providers/dsh-account-models.ts +41 -0
  226. package/src/providers/gcp-credential-cleanup.ts +59 -0
  227. package/src/providers/key-failover.ts +54 -50
  228. package/src/providers/key-store.ts +7 -2
  229. package/src/providers/model-rename-fields.ts +3 -0
  230. package/src/providers/model-rename-migration.ts +2 -2
  231. package/src/providers/openai-tiers-destination.ts +11 -2
  232. package/src/providers/openai-tiers.ts +5 -12
  233. package/src/providers/opencode-go-transport.ts +3 -1
  234. package/src/providers/quota/account-cache.ts +6 -5
  235. package/src/providers/quota/account-row.ts +34 -0
  236. package/src/providers/quota/antigravity.ts +28 -7
  237. package/src/providers/quota/dsh-account.ts +70 -0
  238. package/src/providers/quota/vendor-probes-oauth.ts +1 -93
  239. package/src/providers/quota-types.ts +2 -0
  240. package/src/providers/quota.ts +14 -20
  241. package/src/providers/registry/entries-core.ts +37 -8
  242. package/src/providers/registry/types.ts +1 -1
  243. package/src/providers/request-pacing.ts +77 -19
  244. package/src/providers/routed-config.ts +339 -0
  245. package/src/providers/service-tier.ts +2 -1
  246. package/src/quota/window-mapping.ts +5 -2
  247. package/src/responses/anthropic-progress.ts +34 -0
  248. package/src/responses/assistant-replay.ts +35 -0
  249. package/src/responses/compaction-history-framing.ts +65 -0
  250. package/src/responses/compaction.ts +6 -1
  251. package/src/responses/incomplete-reason.ts +31 -0
  252. package/src/responses/parser.ts +50 -5
  253. package/src/responses/reasoning-envelope.ts +4 -0
  254. package/src/responses/reasoning-replay-cache.ts +24 -2
  255. package/src/responses/reasoning-retention.ts +173 -0
  256. package/src/responses/retained-compaction.ts +46 -0
  257. package/src/responses/schema.ts +23 -0
  258. package/src/router.ts +8 -321
  259. package/src/routing/health.ts +37 -22
  260. package/src/routing/history/indexer.ts +6 -5
  261. package/src/routing/history/ingest.ts +6 -2
  262. package/src/routing/profile.ts +1 -1
  263. package/src/server/admission-model-scope.ts +17 -0
  264. package/src/server/auth-cors.ts +11 -0
  265. package/src/server/chat-completions.ts +7 -1
  266. package/src/server/chat-native-reasoning.ts +123 -0
  267. package/src/server/chat-native-sse.ts +110 -7
  268. package/src/server/chat-native.ts +76 -17
  269. package/src/server/claude-messages.ts +55 -414
  270. package/src/server/effort-policy.ts +1 -1
  271. package/src/server/images.ts +15 -2
  272. package/src/server/index/model-alias-id.ts +36 -0
  273. package/src/server/index/optional-listeners.ts +2 -0
  274. package/src/server/index/serve-options.ts +9 -1
  275. package/src/server/index/startup-warnings.ts +2 -0
  276. package/src/server/index.ts +1 -1
  277. package/src/server/inference/advisor-diagnostics.ts +31 -0
  278. package/src/server/management/advisor-routes.ts +15 -4
  279. package/src/server/management/claude-desktop-picker-routes.ts +1 -1
  280. package/src/server/management/combo-routes.ts +176 -50
  281. package/src/server/management/companion-routes.ts +1 -1
  282. package/src/server/management/config-routes.ts +75 -4
  283. package/src/server/management/cursor-integration-routes.ts +1 -1
  284. package/src/server/management/model-routes.ts +11 -6
  285. package/src/server/management/model-rows.ts +14 -0
  286. package/src/server/management/native-integration-routes.ts +2 -7
  287. package/src/server/management/native-reasoning-retention-routes.ts +41 -0
  288. package/src/server/management/oauth-account-routes.ts +87 -14
  289. package/src/server/management/provider-gcp-credential-routes.ts +29 -0
  290. package/src/server/management/provider-key-pacing-routes.ts +31 -0
  291. package/src/server/management/provider-overwrite-carry.ts +7 -0
  292. package/src/server/management/provider-routes.ts +25 -30
  293. package/src/server/management/route-registry.ts +10 -1
  294. package/src/server/management-api.ts +3 -1
  295. package/src/server/messages-native-tool-guard.ts +185 -0
  296. package/src/server/messages-native.ts +101 -21
  297. package/src/server/messages-shared.ts +431 -0
  298. package/src/server/relay.ts +1 -1
  299. package/src/server/request-log-terminal-status.ts +1 -1
  300. package/src/server/request-log.ts +6 -1
  301. package/src/server/responses/account-change-state.ts +8 -2
  302. package/src/server/responses/adapter-continuation.ts +13 -1
  303. package/src/server/responses/adapter-delivery.ts +19 -6
  304. package/src/server/responses/adapter-dispatch.ts +19 -2
  305. package/src/server/responses/advisor-retention.ts +117 -0
  306. package/src/server/responses/advisor-slot.ts +73 -41
  307. package/src/server/responses/codex-ws-wire.ts +15 -8
  308. package/src/server/responses/compact.ts +303 -125
  309. package/src/server/responses/compaction-recovery.ts +18 -10
  310. package/src/server/responses/compaction-retention.ts +61 -0
  311. package/src/server/responses/context-overflow.ts +11 -6
  312. package/src/server/responses/core-auth.ts +6 -0
  313. package/src/server/responses/core-codex-account.ts +11 -1
  314. package/src/server/responses/core-combo-failure.ts +2 -0
  315. package/src/server/responses/core-combo.ts +120 -36
  316. package/src/server/responses/core-errors.ts +3 -3
  317. package/src/server/responses/core-lifetime.ts +5 -2
  318. package/src/server/responses/core-opaque-recovery.ts +35 -0
  319. package/src/server/responses/core-options.ts +5 -0
  320. package/src/server/responses/core-pipeline.ts +141 -0
  321. package/src/server/responses/core-replay.ts +18 -2
  322. package/src/server/responses/core.ts +2 -115
  323. package/src/server/responses/fetch-helpers.ts +73 -19
  324. package/src/server/responses/main-hard-lock-routing.ts +109 -0
  325. package/src/server/responses/native-response-control.ts +4 -2
  326. package/src/server/responses/passthrough-delivery.ts +3 -1
  327. package/src/server/responses/passthrough-dispatch.ts +15 -13
  328. package/src/server/responses/request-prepare.ts +76 -12
  329. package/src/server/responses/request-sidecar-auth.ts +2 -2
  330. package/src/server/responses/request-transport.ts +104 -27
  331. package/src/server/responses/response-effects.ts +1 -0
  332. package/src/server/responses/run-turn-execution.ts +96 -30
  333. package/src/server/responses/sidecar-execution.ts +20 -2
  334. package/src/server/responses/terminal-guard.ts +2 -16
  335. package/src/service/auth-preflight.ts +71 -0
  336. package/src/service/cli.ts +4 -2
  337. package/src/service/definition.ts +97 -0
  338. package/src/service/guards.ts +1 -61
  339. package/src/service/health-sentinel-linux.ts +112 -0
  340. package/src/service/health-sentinel-main.ts +76 -57
  341. package/src/service/health.ts +5 -89
  342. package/src/service/launchd.ts +27 -7
  343. package/src/service/logging.ts +72 -9
  344. package/src/service/repair.ts +2 -1
  345. package/src/service/state.ts +7 -8
  346. package/src/service/systemd.ts +1 -1
  347. package/src/service/windows-guarded-manager.ts +2 -1
  348. package/src/service/windows-ops.ts +1 -1
  349. package/src/service/windows-scheduler.ts +2 -7
  350. package/src/service/windows-taskxml.ts +2 -2
  351. package/src/service/windows-wscript.ts +14 -0
  352. package/src/service.ts +4 -2
  353. package/src/sidecar/auth.ts +2 -0
  354. package/src/types/config.ts +64 -3
  355. package/src/types/opencode-go.ts +61 -0
  356. package/src/types/opencode-zen.ts +29 -0
  357. package/src/types/provider.ts +32 -6
  358. package/src/types/request.ts +23 -2
  359. package/src/types/wire.ts +29 -37
  360. package/src/types.ts +1 -0
  361. package/src/update/npm-invocation.mjs +12 -2
  362. package/src/usage/cost.ts +48 -12
  363. package/src/usage/expected-prices.ts +3 -0
  364. package/src/usage/ledger-retention.ts +2 -1
  365. package/src/usage/ledger-scanner.ts +6 -5
  366. package/src/usage/log.ts +17 -8
  367. package/src/usage/summary.ts +22 -9
  368. package/src/usage/user-cost-overlays.ts +14 -19
  369. package/src/usage/user-cost-validation.ts +38 -0
  370. package/src/vision/eligibility.ts +13 -0
  371. package/src/web-search/loop.ts +55 -5
  372. package/src/web-search/progress-stream.ts +4 -1
  373. package/src/web-search/run-turn-loop.ts +9 -1
  374. package/gui/dist/assets/App-BjlttVLy.js +0 -58
  375. package/gui/dist/assets/App-CSJvmjNs.css +0 -1
  376. package/gui/dist/assets/index-BB6uK6tU.css +0 -1
  377. package/gui/dist/assets/index-BMssP4k3.js +0 -94
  378. package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
@@ -1,3 +1,4 @@
1
+ import { anthropicProgressItems } from "../responses/anthropic-progress";
1
2
  import type {
2
3
  AdapterEvent,
3
4
  OcxMessagePhase,
@@ -67,6 +68,8 @@ export function buildResponseJSON(
67
68
  if (options?.translatorBudget && options.recordBufferedDelivery !== false) {
68
69
  attemptDeliveryRecorder(budget)?.noteBufferedDelivery(body);
69
70
  }
71
+ if (body.status === "completed" && options?.compaction
72
+ && !events.some(event => event.type === "done" && event.compactionEncryptedContent)) options.compactionRetention?.commit();
70
73
  return body;
71
74
  } finally {
72
75
  for (const event of events) {
@@ -98,6 +101,7 @@ function buildResponseJSONWithBudget(
98
101
  toolSearchToolNames?: Set<string>;
99
102
  /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */
100
103
  compaction?: boolean;
104
+ compactionRetention?: { encode(summary: string): string; commit(): void };
101
105
  onProviderState?: (state: OcxProviderContinuationState) => void;
102
106
  /** Raw adapter-reported usage before wire normalization (see bridgeToResponsesSSE onUsage). */
103
107
  onUsage?: (usage: OcxUsage | undefined) => void;
@@ -386,6 +390,16 @@ function buildResponseJSONWithBudget(
386
390
  flushToolCall();
387
391
  break;
388
392
  case "text_delta":
393
+ if (options?.compaction && e.anthropicThinkingSignature) break;
394
+ if (e.anthropicThinkingSignature) {
395
+ rawReasoningForNextToolCall = "";
396
+ flushText("commentary");
397
+ flushSummaryReasoning();
398
+ flushRawReasoning();
399
+ flushToolCall();
400
+ for (const item of anthropicProgressItems(e.text, e.anthropicThinkingSignature, budget)) pushOutput(item as OutputItem);
401
+ break;
402
+ }
389
403
  // Only flush on an explicit phase change. A later delta that omits `phase` must keep
390
404
  // appending under the previously established phase.
391
405
  if (currentText.bytes > 0 && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
@@ -533,8 +547,9 @@ function buildResponseJSONWithBudget(
533
547
  const safeSources = safeWebSearchSources(e.sources);
534
548
  pushOutput({
535
549
  type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed",
536
- action: webSearchAction(e.queries),
550
+ action: webSearchAction(e.queries, e.fetch),
537
551
  ...(safeSources.length > 0 ? { sources: safeSources } : {}),
552
+ ...(e.fetch ? { fetch_result: e.fetch.result } : {}),
538
553
  });
539
554
  if (safeSources.length > 0) {
540
555
  for (const source of safeSources) {
@@ -616,7 +631,7 @@ function buildResponseJSONWithBudget(
616
631
  ) {
617
632
  const item = {
618
633
  type: "compaction", id: `cmp_${uuid()}`,
619
- encrypted_content: compactionEncryptedContent ?? encodeCompactionSummary(joinChunks(batchCompaction)),
634
+ encrypted_content: compactionEncryptedContent ?? (options?.compactionRetention?.encode(joinChunks(batchCompaction)) ?? encodeCompactionSummary(joinChunks(batchCompaction))),
620
635
  };
621
636
  pushOutput(item, compactionEncryptedContent ? 0 : batchCompaction.bytes);
622
637
  }
@@ -646,7 +661,7 @@ function buildResponseJSONWithBudget(
646
661
  : errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}),
647
662
  ...(incompleteEvent ? {
648
663
  incomplete_details: {
649
- reason: incompleteEvent.reason,
664
+ reason: truncationReasonFor(incompleteEvent.reason) ?? incompleteEvent.reason,
650
665
  ...(incompleteEvent.message ? { message: incompleteEvent.message } : {}),
651
666
  ...(incompleteEvent.retryable !== undefined ? { retryable: incompleteEvent.retryable } : {}),
652
667
  },
package/src/bridge/sse.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { anthropicProgressItems } from "../responses/anthropic-progress";
1
2
  import type {
2
3
  AdapterEvent,
3
4
  OcxMessagePhase,
@@ -12,7 +13,7 @@ import {
12
13
  classifyError,
13
14
  cyberPolicyErrorType,
14
15
  CYBER_POLICY_ERROR_CODE,
15
- isCyberPolicyCode,
16
+ isTerminalRefusalCode,
16
17
  type OcxErrorPayload,
17
18
  } from "../lib/errors";
18
19
  import { redactSecretString } from "../lib/redact";
@@ -50,6 +51,7 @@ import {
50
51
  } from "../lib/translator-budget";
51
52
  import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsCouldBeJson, toolCallArgumentsUsable, uuid, webSearchAction } from "./internal";
52
53
  import type { OutputItem, StringChunks } from "./internal";
54
+ import { terminalRefusalFailure } from "./terminal-refusal";
53
55
 
54
56
  function sseEvent(name: string, data: Record<string, unknown>): string {
55
57
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -88,6 +90,7 @@ export function bridgeToResponsesSSE(
88
90
  * response.completed — codex-rs collect_compaction_output requires exactly one.
89
91
  */
90
92
  compaction?: boolean;
93
+ compactionRetention?: { encode(summary: string): string; commit(): void };
91
94
  /** One-shot: first non-empty text/thinking/raw-reasoning/tool-input delta observed. */
92
95
  onFirstOutput?: () => void;
93
96
  onTerminal?: (status: ResponsesTerminalStatus) => void;
@@ -120,6 +123,12 @@ export function bridgeToResponsesSSE(
120
123
  * would reach the client under the invented name instead of the normalized one.
121
124
  */
122
125
  enforceDeclaredToolNames?: boolean;
126
+ /**
127
+ * Deliver a non-retryable upstream refusal as `response.failed` with a terminal-refusal code
128
+ * instead of `response.incomplete` (see `./terminal-refusal`). Set only for the Responses
129
+ * inbound wire: the chat and Anthropic encoders read the incomplete to report a filter stop.
130
+ */
131
+ terminalRefusalAsFailed?: boolean;
123
132
  /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
124
133
  toolParameterSchemas?: ReadonlyMap<string, Record<string, unknown>>;
125
134
  /**
@@ -676,12 +685,15 @@ export function bridgeToResponsesSSE(
676
685
  // leaves a "Searching the web" spinner spinning forever.
677
686
  // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so
678
687
  // downstream translators (claude outbound) can fill web_search_tool_result content.
679
- const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[]) => {
688
+ // `fetch` (Anthropic's server-side web_fetch) makes the cell a page open and rides its result
689
+ // verbatim as `fetch_result`, for the same claude outbound translator.
690
+ const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: { url: string; result: unknown }) => {
680
691
  if (!currentWebSearch) return;
681
692
  const item = {
682
693
  type: "web_search_call", id: currentWebSearch.itemId, status,
683
- action: webSearchAction(queries),
694
+ action: webSearchAction(queries, fetch),
684
695
  ...(sources && sources.length > 0 ? { sources } : {}),
696
+ ...(fetch ? { fetch_result: fetch.result } : {}),
685
697
  };
686
698
  emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item });
687
699
  retainFinishedItem(item as OutputItem);
@@ -841,6 +853,7 @@ export function bridgeToResponsesSSE(
841
853
  // its compaction UI renders nothing mid-turn, so nothing is lost visually.
842
854
  if (options?.compaction) {
843
855
  if (event.type === "text_delta") {
856
+ if (event.anthropicThinkingSignature) continue;
844
857
  compaction = appendString(
845
858
  compaction,
846
859
  event.text,
@@ -871,6 +884,29 @@ export function bridgeToResponsesSSE(
871
884
  break;
872
885
  }
873
886
  case "text_delta": {
887
+ if (event.anthropicThinkingSignature) {
888
+ rawReasoningForNextToolCall = "";
889
+ closeCurrentMessage("commentary");
890
+ closeCurrentReasoning();
891
+ closeCurrentRawReasoning();
892
+ flushHiddenRawReasoning();
893
+ flushHiddenReasoningEnvelope();
894
+ if (currentToolCall) closeCurrentToolCall();
895
+ for (const item of anthropicProgressItems(event.text, event.anthropicThinkingSignature, budget)) {
896
+ emit("response.output_item.added", { output_index: outputIndex, item: { ...item, ...(item.type === "message" ? { status: "in_progress", content: [] } : {}) } });
897
+ if (item.type === "message") {
898
+ const part = item.content![0];
899
+ emit("response.content_part.added", { item_id: item.id, output_index: outputIndex, content_index: 0, part: { ...part, text: "" } });
900
+ emit("response.output_text.delta", { item_id: item.id, output_index: outputIndex, content_index: 0, delta: event.text });
901
+ emit("response.output_text.done", { item_id: item.id, output_index: outputIndex, content_index: 0, text: event.text });
902
+ emit("response.content_part.done", { item_id: item.id, output_index: outputIndex, content_index: 0, part });
903
+ }
904
+ emit("response.output_item.done", { output_index: outputIndex, item });
905
+ retainFinishedItem(item as OutputItem);
906
+ outputIndex++;
907
+ }
908
+ break;
909
+ }
874
910
  if (currentReasoning) closeCurrentReasoning();
875
911
  if (currentRawReasoning) closeCurrentRawReasoning();
876
912
  flushHiddenRawReasoning();
@@ -1195,7 +1231,7 @@ export function bridgeToResponsesSSE(
1195
1231
  currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex };
1196
1232
  }
1197
1233
  const safeSources = safeWebSearchSources(event.sources);
1198
- closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources);
1234
+ closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.fetch);
1199
1235
  // Queue this search's sources for the next assistant message (dedup by URL).
1200
1236
  if (safeSources.length > 0) {
1201
1237
  for (const source of safeSources) {
@@ -1233,7 +1269,7 @@ export function bridgeToResponsesSSE(
1233
1269
  // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
1234
1270
  const item = {
1235
1271
  type: "compaction", id: `cmp_${uuid()}`,
1236
- encrypted_content: event.compactionEncryptedContent ?? encodeCompactionSummary(joinChunks(compaction)),
1272
+ encrypted_content: event.compactionEncryptedContent ?? (options?.compactionRetention?.encode(joinChunks(compaction)) ?? encodeCompactionSummary(joinChunks(compaction))),
1237
1273
  };
1238
1274
  emit("response.output_item.done", { output_index: outputIndex, item });
1239
1275
  retainFinishedItem(item as OutputItem, event.compactionEncryptedContent ? 0 : compaction.bytes);
@@ -1271,6 +1307,7 @@ export function bridgeToResponsesSSE(
1271
1307
  emit("response.completed", {
1272
1308
  response,
1273
1309
  });
1310
+ if (!closed && !terminated && options?.compaction && !event.compactionEncryptedContent) options.compactionRetention?.commit();
1274
1311
  reportTerminal("completed");
1275
1312
  }
1276
1313
  terminalEvent = true;
@@ -1287,12 +1324,25 @@ export function bridgeToResponsesSSE(
1287
1324
  flushHiddenReasoningEnvelope();
1288
1325
  options?.onUsage?.(event.usage);
1289
1326
  await awaitThoughtSignatureDurability();
1327
+ const refusal = options?.terminalRefusalAsFailed ? terminalRefusalFailure(event) : undefined;
1328
+ if (refusal) {
1329
+ emit("response.failed", {
1330
+ response: {
1331
+ ...responseSnapshot("failed", finishedItems, event.endTurn),
1332
+ usage: responsesUsage(event.usage),
1333
+ ...refusal,
1334
+ },
1335
+ });
1336
+ reportTerminal("failed");
1337
+ terminalEvent = true;
1338
+ break;
1339
+ }
1290
1340
  emit("response.incomplete", {
1291
1341
  response: {
1292
1342
  ...responseSnapshot("incomplete", finishedItems, event.endTurn),
1293
1343
  usage: responsesUsage(event.usage),
1294
1344
  incomplete_details: {
1295
- reason: event.reason,
1345
+ reason: truncationReasonFor(event.reason) ?? event.reason,
1296
1346
  ...(event.message ? { message: event.message } : {}),
1297
1347
  ...(event.retryable !== undefined ? { retryable: event.retryable } : {}),
1298
1348
  },
@@ -1325,7 +1375,7 @@ export function bridgeToResponsesSSE(
1325
1375
  ...(event.usage ? { usage: responsesUsage(event.usage) } : {}),
1326
1376
  error: failure.error,
1327
1377
  last_error: failure.error,
1328
- ...(isCyberPolicyCode(failure.error.code)
1378
+ ...(isTerminalRefusalCode(failure.error.code)
1329
1379
  ? { retryable: false }
1330
1380
  : event.retryable !== undefined ? { retryable: event.retryable } : {}),
1331
1381
  },
@@ -1366,7 +1416,7 @@ export function bridgeToResponsesSSE(
1366
1416
  ...responseSnapshot("failed", finishedItems),
1367
1417
  error: failure,
1368
1418
  last_error: failure,
1369
- ...(isCyberPolicyCode(failure.code) ? { retryable: false } : {}),
1419
+ ...(isTerminalRefusalCode(failure.code) ? { retryable: false } : {}),
1370
1420
  },
1371
1421
  });
1372
1422
  reportTerminal("failed");
@@ -0,0 +1,59 @@
1
+ import type { AdapterEvent } from "../types";
2
+ import type { OcxErrorPayload } from "../lib/errors";
3
+ import { redactSecretString } from "../lib/redact";
4
+
5
+ /**
6
+ * The terminal-refusal code Codex ends a turn on. It is a member of `TERMINAL_REFUSAL_CODES`
7
+ * in `src/lib/errors.ts`, and the one that says "this prompt, as sent, is refused" without
8
+ * claiming a specific policy family the upstream never named.
9
+ */
10
+ const TERMINAL_REFUSAL_FAILED_CODE = "invalid_prompt";
11
+
12
+ interface TerminalRefusalFailure {
13
+ error: OcxErrorPayload;
14
+ last_error: OcxErrorPayload;
15
+ retryable: false;
16
+ }
17
+
18
+ /**
19
+ * Client-visible copy: what happened, and that resending the same context cannot help. It points
20
+ * at a new conversation rather than a new turn, because a later turn replays the refused context.
21
+ */
22
+ function terminalRefusalMessage(detail: string | undefined): string {
23
+ const reason = detail?.trim() ? ` (${redactSecretString(detail.trim())})` : "";
24
+ return `The upstream model refused this request${reason}. `
25
+ + "Retrying with the same conversation context will not succeed; change the request or start a new conversation.";
26
+ }
27
+
28
+ /**
29
+ * An adapter `incomplete` that is a non-retryable upstream refusal: a content filter carrying an
30
+ * explicit `retryable: false` verdict. A filter without the verdict, an output cap, a stall and an
31
+ * adapter EOF do not qualify. The sidecar loops read this too: a refused iteration ends the turn,
32
+ * so a sidecar call it carried is neither run nor answered with another model request.
33
+ */
34
+ export function isTerminalRefusalIncomplete(event: AdapterEvent | undefined): boolean {
35
+ return event?.type === "incomplete" && event.reason === "content_filter" && event.retryable === false;
36
+ }
37
+
38
+ /**
39
+ * `response.failed` fields for an adapter `incomplete` that is a non-retryable upstream refusal
40
+ * (#4312), or `undefined` when the event is anything else. The Anthropic and Kiro adapters are
41
+ * the ones that emit it today.
42
+ *
43
+ * codex-rs maps every `response.incomplete` to a retryable stream error and never reads
44
+ * `incomplete_details.retryable`, so a refusal delivered that way is re-sent through the whole
45
+ * stream retry budget. It only ends the turn on `response.failed` carrying a terminal-refusal
46
+ * `error.code`. Only an explicit `retryable: false` content filter qualifies: a filter without
47
+ * the verdict, an output cap, a stall and an adapter EOF stay `response.incomplete`.
48
+ */
49
+ export function terminalRefusalFailure(
50
+ event: Extract<AdapterEvent, { type: "incomplete" }>,
51
+ ): TerminalRefusalFailure | undefined {
52
+ if (!isTerminalRefusalIncomplete(event)) return undefined;
53
+ const error: OcxErrorPayload = {
54
+ type: "invalid_request_error",
55
+ code: TERMINAL_REFUSAL_FAILED_CODE,
56
+ message: terminalRefusalMessage(event.message),
57
+ };
58
+ return { error, last_error: error, retryable: false };
59
+ }
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs";
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
@@ -84,9 +84,20 @@ export function readDesktopLibraryText(path: string): string {
84
84
  return readDesktopLibraryBytes(path).toString("utf8");
85
85
  }
86
86
 
87
+ /** Only an absent directory entry is optional; dangling links and failed reads still refuse. */
88
+ export function readOptionalDesktopLibraryBytes(path: string): Buffer | undefined {
89
+ try { return readDesktopLibraryBytes(path); }
90
+ catch (error) {
91
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ /** Read optional verified metadata; a present file must contain an entries array. */
87
97
  export function parseMetadata(path: string): Desktop3pMetadata {
88
- if (!existsSync(path)) return { entries: [] };
89
- const parsed = JSON.parse(readDesktopLibraryText(path)) as Partial<Desktop3pMetadata>;
98
+ const bytes = readOptionalDesktopLibraryBytes(path);
99
+ if (bytes === undefined) return { entries: [] };
100
+ const parsed = JSON.parse(bytes.toString("utf8")) as Partial<Desktop3pMetadata>;
90
101
  if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array");
91
102
  return { ...parsed, entries: parsed.entries };
92
103
  }
@@ -120,11 +131,25 @@ export const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([
120
131
  "inferenceModels",
121
132
  ]);
122
133
 
123
- export function readDesktopProfileForeignKeys(path: string): Record<string, unknown> {
124
- if (!existsSync(path)) return {};
125
- const parsed = JSON.parse(readDesktopLibraryText(path)) as unknown;
134
+ export interface DesktopProfileSnapshot {
135
+ /** Verified bytes of the existing profile; undefined only when the entry is absent. */
136
+ bytes?: Buffer;
137
+ foreign: Record<string, unknown>;
138
+ }
139
+
140
+ /** Capture verified bytes for key preservation, backup and rollback; publication revalidates them. */
141
+ export function readDesktopProfileSnapshot(path: string): DesktopProfileSnapshot {
142
+ const bytes = readOptionalDesktopLibraryBytes(path);
143
+ if (bytes === undefined) return { foreign: {} };
144
+ const parsed = JSON.parse(bytes.toString("utf8")) as unknown;
126
145
  if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object");
127
- return Object.fromEntries(
146
+ const foreign = Object.fromEntries(
128
147
  Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)),
129
148
  );
149
+ return { bytes, foreign };
150
+ }
151
+
152
+ /** Preserve settings outside the gateway-owned keys from a verified profile. */
153
+ export function readDesktopProfileForeignKeys(path: string): Record<string, unknown> {
154
+ return readDesktopProfileSnapshot(path).foreign;
130
155
  }
@@ -5,15 +5,16 @@ import { applyRemoteDesktopStore, inspectRemoteDesktopCleanup, restoreRemoteDesk
5
5
  import { canonicalDirectory } from "./desktop-remote-store-io";
6
6
  import {
7
7
  resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord,
8
- isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys, readDesktopLibraryText, readDesktopLibraryBytes,
8
+ isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileSnapshot, readDesktopLibraryText, readDesktopLibraryBytes,
9
+ readOptionalDesktopLibraryBytes,
9
10
  type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry,
10
11
  } from "./desktop-3p-library";
11
12
  export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library";
12
13
  import { createHash, randomUUID } from "node:crypto";
13
- import { copyFileSync, existsSync, mkdirSync, lstatSync, unlinkSync } from "node:fs";
14
+ import { existsSync, mkdirSync, lstatSync, unlinkSync, writeFileSync } from "node:fs";
14
15
  import { join } from "node:path";
15
16
  import { atomicWriteFile, readConfigDiagnostics, withConfigMutationLockSync } from "../config";
16
- import { atomicWriteFileNoFollow } from "../config/atomic-write";
17
+ import { atomicWriteFileNoFollow, atomicWriteFileStreamed } from "../config/atomic-write";
17
18
  import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
18
19
  import type { OcxClaudeDesktopProfile } from "../types";
19
20
  import {
@@ -800,6 +801,7 @@ function desktopRefreshPathsSafe(libraryPath: string, configPath?: string): bool
800
801
  } catch { return false; }
801
802
  }
802
803
 
804
+ /** Publish a gateway profile from a verified snapshot, rolling it back if metadata fails. */
803
805
  function writeDesktop3pConfigWithGenerator(
804
806
  generate: () => object,
805
807
  refreshOnly?: { requiredId: string; expectedFingerprint: string; admit: () => boolean },
@@ -828,8 +830,8 @@ function writeDesktop3pConfigWithGenerator(
828
830
  : [...metadata.entries, entry];
829
831
 
830
832
  const generated = generate();
831
- const preserved = readDesktopProfileForeignKeys(configPath);
832
- const configJson = JSON.stringify({ ...preserved, ...generated }, null, 2) + "\n";
833
+ const previous = readDesktopProfileSnapshot(configPath);
834
+ const configJson = JSON.stringify({ ...previous.foreign, ...generated }, null, 2) + "\n";
833
835
  const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
834
836
  if (refreshOnly) {
835
837
  atomicWriteFileNoFollow(configPath, configJson, undefined, {
@@ -847,12 +849,31 @@ function writeDesktop3pConfigWithGenerator(
847
849
  });
848
850
  return { written: true, path: configPath, fingerprint };
849
851
  }
850
- const { backupPath } = atomicReplaceDesktopConfig(configPath, configJson);
852
+ atomicReplaceDesktopConfig(configPath, configJson, previous.bytes);
851
853
  try {
852
854
  atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n");
853
855
  } catch (metaError) {
854
- // Rollback: restore the backed-up config if metadata write fails.
855
- if (backupPath && existsSync(backupPath)) copyFileSync(backupPath, configPath);
856
+ // Roll back from the verified bytes in memory: reopening the backup could meet a swapped
857
+ // entry and leave the new profile beside the old metadata. A profile this call created is
858
+ // unreferenced once metadata failed, so remove it rather than orphan one per retry.
859
+ // Rollback is ownership-aware: it acts only while the live profile still holds the bytes
860
+ // this call published, checked by the same comparison that guarded publication.
861
+ const base = metaError instanceof Error ? metaError.message : String(metaError);
862
+ const published = Buffer.from(configJson);
863
+ let owned = false;
864
+ const stillOwned = () => { owned = false; assertDesktopProfileUnchanged(configPath, published); owned = true; };
865
+ try {
866
+ stillOwned();
867
+ if (previous.bytes !== undefined) writeDesktopConfigBytes(configPath, previous.bytes, stillOwned);
868
+ else unlinkSync(configPath);
869
+ } catch (rollbackError) {
870
+ if (!owned) {
871
+ // Another writer replaced the profile after publication; its bytes win over our rollback.
872
+ throw new Error(`${base} (profile rollback skipped: profile changed after publish)`, { cause: metaError });
873
+ }
874
+ const detail = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
875
+ throw new AggregateError([metaError, rollbackError], `${base} (profile rollback also failed: ${detail})`, { cause: metaError });
876
+ }
856
877
  throw metaError;
857
878
  }
858
879
  return { written: true, path: configPath, fingerprint };
@@ -862,14 +883,35 @@ function writeDesktop3pConfigWithGenerator(
862
883
  }
863
884
  }
864
885
 
865
- /** Backup an existing owned config then atomically replace it. Exported for failure-path tests. */
886
+ /**
887
+ * Compare the live profile with the caller's verified bytes before backup and before replacement.
888
+ * Refuse drift without retrying; backup and rollback continue to use the original verified bytes.
889
+ * `previous` is undefined only for an absent profile, which leaves any older backup alone.
890
+ * Exported for failure-path tests.
891
+ */
866
892
  export function atomicReplaceDesktopConfig(
867
893
  path: string,
868
894
  content: string,
869
- writer: (path: string, content: string) => void = atomicWriteFile,
895
+ previous: Buffer | undefined,
896
+ writer: typeof atomicWriteFileNoFollow = atomicWriteFileNoFollow,
870
897
  ): { backupPath?: string } {
898
+ const validateBeforeRename = () => assertDesktopProfileUnchanged(path, previous);
899
+ validateBeforeRename();
871
900
  const backupPath = `${path}.bak`;
872
- if (existsSync(path)) copyFileSync(path, backupPath);
873
- writer(path, content);
874
- return existsSync(backupPath) ? { backupPath } : {};
901
+ if (previous !== undefined) writeDesktopConfigBytes(backupPath, previous);
902
+ writer(path, content, undefined, { validateBeforeRename });
903
+ return previous === undefined ? {} : { backupPath };
904
+ }
905
+
906
+ /** Single compare-and-swap check: the live profile must still hold `expected` (undefined = absent). */
907
+ function assertDesktopProfileUnchanged(path: string, expected: Buffer | undefined): void {
908
+ const current = readOptionalDesktopLibraryBytes(path);
909
+ if (expected === undefined ? current !== undefined : current === undefined || !expected.equals(current)) {
910
+ throw new Error("desktop_profile_changed");
911
+ }
912
+ }
913
+
914
+ /** Publish the verified bytes without reopening the source or decoding its backup contents. */
915
+ function writeDesktopConfigBytes(path: string, bytes: Buffer, validateBeforeRename?: () => void): void {
916
+ atomicWriteFileStreamed(path, descriptor => { writeFileSync(descriptor, bytes); }, validateBeforeRename ? { validateBeforeRename } : {});
875
917
  }
@@ -25,7 +25,7 @@ import type { OcxConfig } from "../types";
25
25
  import { claudeConfigDir } from "./auth-detect";
26
26
  import { inspectDesktop3pConfigLibrary } from "./desktop-3p";
27
27
  import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca";
28
- import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime";
28
+ import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/port-policy";
29
29
  import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./intercept/proxy-auth";
30
30
  import {
31
31
  applyClaudeInterceptSettings,
@@ -0,0 +1,4 @@
1
+ /** Platforms with a Desktop egress profile and an OS trust adapter for the picker. */
2
+ export function supportsDesktopPicker(platform: NodeJS.Platform = process.platform): boolean {
3
+ return platform === "darwin" || platform === "win32";
4
+ }
@@ -23,6 +23,7 @@ import {
23
23
  type DesktopPickerProfileOptions,
24
24
  } from "./desktop-picker-profile";
25
25
  import { resolveClaudeDesktopMode, observeClaudeDesktopMode } from "./desktop-first-party";
26
+ import { supportsDesktopPicker } from "./desktop-picker-platform";
26
27
 
27
28
  /** Desktop no longer selects the picker profile, so removing the CA's trust cannot strand it. */
28
29
  function profileReleased(profile: DesktopPickerProfileInspection): boolean {
@@ -77,6 +78,8 @@ export interface DesktopPickerControllerDeps {
77
78
  persistPreference: (value: boolean) => boolean;
78
79
  /** Bound picker CONNECT proxy port (Desktop's egress), or null when it is not running. */
79
80
  proxyPort: () => number | null;
81
+ /** Bind a dormant relay on explicit enable, under the controller's mutation lock. */
82
+ ensureProxy?: () => Promise<void>;
80
83
  configDir: string;
81
84
  persistentAuthority?: { store?: PickerCaStore };
82
85
  security?: SecurityRunner;
@@ -91,7 +94,7 @@ function profileOptions(deps: DesktopPickerControllerDeps): DesktopPickerProfile
91
94
  }
92
95
 
93
96
  function statusReasonForConfig(config: OcxConfig, platform: NodeJS.Platform, proxyBound: boolean): DesktopPickerReason {
94
- if (platform !== "darwin") return "unsupported_platform";
97
+ if (!supportsDesktopPicker(platform)) return "unsupported_platform";
95
98
  if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "not_first_party";
96
99
  if (!claudeDesktopIntegrationEnabled(config)) return "integration_off";
97
100
  if (config.claudeCode?.intercept?.picker === false) return "disabled";
@@ -101,11 +104,11 @@ function statusReasonForConfig(config: OcxConfig, platform: NodeJS.Platform, pro
101
104
 
102
105
  function emptyStatus(config: OcxConfig, platform: NodeJS.Platform, reason: DesktopPickerReason): DesktopPickerStatus {
103
106
  return {
104
- desired: platform === "darwin"
107
+ desired: supportsDesktopPicker(platform)
105
108
  && claudeDesktopIntegrationEnabled(config)
106
109
  && resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) === "first-party"
107
110
  && config.claudeCode?.intercept?.picker !== false,
108
- supported: platform === "darwin",
111
+ supported: supportsDesktopPicker(platform),
109
112
  trust: "unknown",
110
113
  profile: "absent",
111
114
  listenerReady: false,
@@ -142,7 +145,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps)
142
145
  const trust = trustOverride ?? runtime.trust;
143
146
  const effective = runtime.effective && profileCurrent && trust === "trusted";
144
147
  let reason: DesktopPickerReason;
145
- if (platform !== "darwin") reason = "unsupported_platform";
148
+ if (!supportsDesktopPicker(platform)) reason = "unsupported_platform";
146
149
  else if (!runtime.desired) reason = statusReasonForConfig(deps.readConfig(), platform, deps.proxyPort() !== null);
147
150
  else if (deps.proxyPort() === null) reason = "proxy_unavailable";
148
151
  else if (trust !== "trusted") reason = "trust_pending";
@@ -178,7 +181,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps)
178
181
 
179
182
  async function untrustCurrentCa(): Promise<boolean> {
180
183
  const caPath = pickerCaCertPath(deps.configDir);
181
- if (platform !== "darwin" || !existsSync(caPath)) return true;
184
+ if (!supportsDesktopPicker(platform) || !existsSync(caPath)) return true;
182
185
  try {
183
186
  const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1;
184
187
  return (await untrustPickerCa(caPath, sha1, deps.security, platform)).ok;
@@ -207,11 +210,11 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps)
207
210
  return withTrustFailure({ ...runtimeStatus(observedTrust), reason }, trustFailed);
208
211
  };
209
212
  const check = (config: OcxConfig, includePreference: boolean): DesktopPickerReason | null => {
210
- if (platform !== "darwin") return "unsupported_platform";
213
+ if (!supportsDesktopPicker(platform)) return "unsupported_platform";
211
214
  if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "mode_not_committed";
212
215
  if (!claudeDesktopIntegrationEnabled(config)) return "integration_off";
213
216
  if (includePreference && config.claudeCode?.intercept?.picker === false) return "disabled";
214
- if (deps.proxyPort() === null) return "proxy_unavailable";
217
+ if (deps.proxyPort() === null && !deps.ensureProxy) return "proxy_unavailable";
215
218
  return null;
216
219
  };
217
220
 
@@ -224,6 +227,10 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps)
224
227
  }
225
228
  const second = check(deps.readConfig(), true);
226
229
  if (second) return refuse(second);
230
+ if (deps.proxyPort() === null) {
231
+ try { await deps.ensureProxy?.(); } catch { return refuse("proxy_unavailable"); }
232
+ }
233
+ if (deps.proxyPort() === null) return refuse("proxy_unavailable");
227
234
 
228
235
  let caPath: string;
229
236
  let caSha1: string;
@@ -336,7 +343,7 @@ export async function removeDesktopPickerArtifacts(options: { configDir?: string
336
343
  return { ok: false, residual };
337
344
  }
338
345
  const caPath = pickerCaCertPath(configDir);
339
- if (platform === "darwin" && existsSync(caPath)) {
346
+ if (supportsDesktopPicker(platform) && existsSync(caPath)) {
340
347
  try {
341
348
  const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1;
342
349
  if (!(await untrustPickerCa(caPath, sha1, options.security, platform)).ok) residual.push("trust");