@bitkyc08/opencodex 2.64.0 → 2.65.0-preview.20260925

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 (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-CsYvvpr3.js +50 -0
  5. package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-BB0iHG8-.js} +12 -12
  8. package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -214,6 +214,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
214
214
  const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
215
215
  const admitted = loadConfig();
216
216
  if (!claudeDesktopIntegrationEnabled(admitted)) return;
217
+ // A first-party Desktop must never get a gateway profile written and selected behind it.
218
+ const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party");
219
+ if (resolveClaudeDesktopMode(admitted, observeClaudeDesktopMode(admitted)) === "first-party") return;
217
220
  if (admitted.claudeCode?.desktopAutoApply === false) return;
218
221
  if (!admitted.claudeCode?.desktopProfile) return;
219
222
  const { inspectDesktop3pConfigLibrary, writeDesktop3pConfig } = await import("../../claude/desktop-3p");
@@ -223,32 +226,38 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
223
226
  if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(beforeKind)) return;
224
227
  const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
225
228
  const allModels = await (deps.fetchAllModels ?? fetchAllModels)(admitted);
226
- const current = loadConfig();
227
- // This is the real guard: the catalog await admits a concurrent explicit OFF.
228
- if (!claudeDesktopIntegrationEnabled(current)) return;
229
- if (current.claudeCode?.desktopAutoApply === false || !current.claudeCode?.desktopProfile) return;
230
- const afterKind = inspectDesktop3pConfigLibrary({
231
- appliedFingerprint: current.claudeCode.desktopProfile.appliedFingerprint ?? null,
232
- }).kind;
233
- if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(afterKind)) return;
234
- const routed = filterCatalogVisibleModels(allModels, current).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
235
- const writtenProfile = current.claudeCode.desktopProfile;
236
- const markerBaseline = captureDesktopAppliedMarker(writtenProfile);
237
- const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
238
- current.port ?? 10100,
239
- [...desktopVisibleNativeSlugs(current)],
240
- routed,
241
- current.apiKeys?.[0]?.key,
242
- "static",
243
- writtenProfile,
244
- nativeContextLimits(current),
245
- );
246
- if (result.written && result.fingerprint) {
247
- const marked = commitDesktopAppliedMarker(markerBaseline, result.fingerprint);
248
- if (marked.status === "unavailable" || marked.value === false) {
249
- console.warn("[claude-desktop] provider-change applied marker skipped");
229
+ // Serialized with Desktop mode transitions: a first-party switch cannot interleave with this write.
230
+ const { runPickerTransition } = await import("./claude-desktop-picker-routes");
231
+ await runPickerTransition(admitted, async () => {
232
+ const current = loadConfig();
233
+ // This is the real guard: the catalog await admits a concurrent explicit OFF.
234
+ if (!claudeDesktopIntegrationEnabled(current)) return undefined;
235
+ // …and a concurrent switch to first-party.
236
+ if (resolveClaudeDesktopMode(current, observeClaudeDesktopMode(current)) === "first-party") return undefined;
237
+ if (current.claudeCode?.desktopAutoApply === false || !current.claudeCode?.desktopProfile) return undefined;
238
+ const afterKind = inspectDesktop3pConfigLibrary({
239
+ appliedFingerprint: current.claudeCode.desktopProfile.appliedFingerprint ?? null,
240
+ }).kind;
241
+ if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(afterKind)) return undefined;
242
+ const routed = filterCatalogVisibleModels(allModels, current).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
243
+ const writtenProfile = current.claudeCode.desktopProfile;
244
+ const markerBaseline = captureDesktopAppliedMarker(writtenProfile);
245
+ const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
246
+ current.port ?? 10100,
247
+ [...desktopVisibleNativeSlugs(current)],
248
+ routed,
249
+ current.apiKeys?.[0]?.key,
250
+ "static",
251
+ writtenProfile,
252
+ nativeContextLimits(current),
253
+ );
254
+ if (result.written && result.fingerprint) {
255
+ const marked = commitDesktopAppliedMarker(markerBaseline, result.fingerprint);
256
+ if (marked.status === "unavailable" || marked.value === false) {
257
+ console.warn("[claude-desktop] provider-change applied marker skipped");
258
+ }
250
259
  }
251
- }
260
+ });
252
261
  } catch { /* best-effort */ }
253
262
  }
254
263
 
@@ -1068,9 +1077,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1068
1077
  rethrowManagementBodyTooLarge(error);
1069
1078
  return jsonResponse({ error: "invalid JSON body" }, 400);
1070
1079
  }
1071
- const { resolveClaudeDesktopApplyMode, applyDesktopFirstParty, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party");
1080
+ const { resolveClaudeDesktopApplyMode, observeClaudeDesktopMode, applyDesktopFirstParty, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party");
1072
1081
  const requested = (parsed as { mode?: unknown } | null)?.mode;
1073
- let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config);
1082
+ let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config));
1074
1083
  if (requested !== undefined) {
1075
1084
  if (requested === "static" || requested === "hybrid" || requested === "discovery") {
1076
1085
  mode = requested;
@@ -1099,53 +1108,64 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1099
1108
  const desired = setIntegrationEnabled("claude-desktop", true);
1100
1109
  if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
1101
1110
  mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true);
1102
- const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p");
1103
- const rollback = captureDesktopFirstPartyRollback(config);
1104
- const applied = applyDesktopFirstParty(config);
1105
- if (!applied.ok) {
1106
- const { firstPartyRefusalMessage } = await import("./native-integration-routes");
1107
- return jsonResponse({
1108
- error: firstPartyRefusalMessage(applied.reason, applied.path),
1109
- code: "claude_desktop_first_party_refused",
1110
- reason: applied.reason,
1111
- path: applied.path,
1112
- }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500);
1113
- }
1114
- // Remove the gateway only after the replacement env was written.
1115
- const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
1116
- const library = inspectDesktop3pConfigLibrary({ appliedFingerprint });
1117
- let gatewayRemoved = false;
1118
- if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") {
1119
- const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true });
1120
- if (!removed.ok) {
1121
- const restored = removed.changed || !applied.changed || rollback();
1122
- const modeSaved = !removed.changed || (await persistDesktopModeField(config, "first-party")).ok;
1123
- const warning = [restored ? "" : "first-party settings rollback did not complete",
1124
- modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; ");
1111
+ const { pickerPreferenceOn, pickerStatusFor, runPickerTransition } = await import("./claude-desktop-picker-routes");
1112
+ // The whole switch runs under the picker lock, in today's order: env first, then gateway cleanup.
1113
+ return await runPickerTransition(config, async ops => {
1114
+ const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p");
1115
+ const rollback = captureDesktopFirstPartyRollback(config);
1116
+ const applied = applyDesktopFirstParty(config);
1117
+ if (!applied.ok) {
1118
+ const { firstPartyRefusalMessage } = await import("./native-integration-routes");
1125
1119
  return jsonResponse({
1126
- error: removed.kind === "cleanup_incomplete"
1127
- ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active."
1128
- : "The gateway profile could not be removed safely, so first-party mode was not applied.",
1129
- code: "claude_desktop_gateway_removal_failed",
1130
- ...(warning ? { warning } : {}),
1131
- reason: removed.reason ?? removed.kind,
1132
- ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}),
1133
- }, removed.kind === "cleanup_incomplete" ? 500 : 409);
1120
+ error: firstPartyRefusalMessage(applied.reason, applied.path),
1121
+ code: "claude_desktop_first_party_refused",
1122
+ reason: applied.reason,
1123
+ path: applied.path,
1124
+ }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500);
1134
1125
  }
1135
- gatewayRemoved = removed.changed;
1136
- }
1137
- const modeSaved = await persistDesktopModeField(config, "first-party");
1138
- return jsonResponse({
1139
- ok: true,
1140
- mode: "first-party",
1141
- saved: modeSaved.ok,
1142
- applied: true,
1143
- changed: applied.changed || gatewayRemoved,
1144
- gatewayRemoved,
1145
- path: applied.path,
1146
- proxyPort: applied.proxyPort,
1147
- caCertPath: applied.env.NODE_EXTRA_CA_CERTS,
1148
- ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }),
1126
+ // Remove the gateway only after the replacement env was written.
1127
+ const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
1128
+ const library = inspectDesktop3pConfigLibrary({ appliedFingerprint });
1129
+ let gatewayRemoved = false;
1130
+ if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") {
1131
+ const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true });
1132
+ if (!removed.ok) {
1133
+ const restored = removed.changed || !applied.changed || rollback();
1134
+ const modeSaved = !removed.changed || (await persistDesktopModeField(config, "first-party")).ok;
1135
+ const warning = [restored ? "" : "first-party settings rollback did not complete",
1136
+ modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; ");
1137
+ return jsonResponse({
1138
+ error: removed.kind === "cleanup_incomplete"
1139
+ ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active."
1140
+ : "The gateway profile could not be removed safely, so first-party mode was not applied.",
1141
+ code: "claude_desktop_gateway_removal_failed",
1142
+ ...(warning ? { warning } : {}),
1143
+ reason: removed.reason ?? removed.kind,
1144
+ ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}),
1145
+ }, removed.kind === "cleanup_incomplete" ? 500 : 409);
1146
+ }
1147
+ gatewayRemoved = removed.changed;
1148
+ }
1149
+ const modeSaved = await persistDesktopModeField(config, "first-party");
1150
+ const { firstPartyAccountRisk } = await import("../../claude/desktop-risk");
1151
+ // Picker mode is on by default in first-party; only a committed mode turns it on.
1152
+ const picker = modeSaved.ok && pickerPreferenceOn(loadConfig())
1153
+ ? await ops.enableLocked({ persist: false, context: "server" })
1154
+ : await pickerStatusFor(loadConfig());
1155
+ return jsonResponse({
1156
+ ok: true,
1157
+ mode: "first-party",
1158
+ saved: modeSaved.ok,
1159
+ applied: true,
1160
+ changed: applied.changed || gatewayRemoved,
1161
+ gatewayRemoved,
1162
+ path: applied.path,
1163
+ proxyPort: applied.proxyPort,
1164
+ caCertPath: applied.env.NODE_EXTRA_CA_CERTS,
1165
+ riskWarning: firstPartyAccountRisk(),
1166
+ picker,
1167
+ ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }),
1168
+ });
1149
1169
  });
1150
1170
  }
1151
1171
  const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
@@ -1176,47 +1196,58 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1176
1196
  const slash = model.route.indexOf("/");
1177
1197
  return { provider: model.route.slice(0, slash), id: model.route.slice(slash + 1), contextWindow: model.contextWindow };
1178
1198
  });
1179
- // State construction can await catalog work; never write from the stale
1180
- // config captured before that await if another request turned Desktop off.
1181
- const latest = loadConfig();
1182
- if (!claudeDesktopIntegrationEnabled(latest)) {
1183
- return jsonResponse({
1184
- error: "Claude Desktop apply was cancelled because the desired state changed to off.",
1185
- code: "claude_desktop_apply_skipped",
1186
- reason: "desired_state_changed",
1187
- desiredEnabled: false,
1188
- saved: true,
1189
- applied: false,
1190
- }, 409);
1191
- }
1192
- const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
1193
- Number(url.port) || latest.port,
1194
- [...desktopVisibleNativeSlugs(latest)],
1195
- routed,
1196
- latest.apiKeys?.[0]?.key,
1197
- mode,
1198
- state.profile,
1199
- nativeContextLimits(latest),
1200
- );
1201
- if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500);
1202
- const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint);
1203
- const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`;
1204
- // The durable marker describes the committed gateway even if old-mode cleanup fails.
1205
- const firstPartyRemoved = removeDesktopFirstParty();
1206
- if (!firstPartyRemoved.ok) {
1207
- return jsonResponse({
1208
- error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`,
1209
- code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason,
1210
- applied: true, saved: committed.ok, mode: "gateway", path: result.path,
1211
- ...(modeWarning ? { warning: modeWarning } : {}),
1212
- }, 500);
1213
- }
1199
+ const { runPickerTransition } = await import("./claude-desktop-picker-routes");
1200
+ const outcome = await runPickerTransition(config, async ops => {
1201
+ // State construction can await catalog work; never write from the stale
1202
+ // config captured before that await if another request turned Desktop off.
1203
+ const latest = loadConfig();
1204
+ if (!claudeDesktopIntegrationEnabled(latest)) {
1205
+ return { response: jsonResponse({
1206
+ error: "Claude Desktop apply was cancelled because the desired state changed to off.",
1207
+ code: "claude_desktop_apply_skipped",
1208
+ reason: "desired_state_changed",
1209
+ desiredEnabled: false,
1210
+ saved: true,
1211
+ applied: false,
1212
+ }, 409) };
1213
+ }
1214
+ // Picker mode belongs to first-party: stop terminating claude.ai before the gateway lands.
1215
+ const pickerOff = await ops.disableLocked({ persist: false });
1216
+ const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
1217
+ Number(url.port) || latest.port,
1218
+ [...desktopVisibleNativeSlugs(latest)],
1219
+ routed,
1220
+ latest.apiKeys?.[0]?.key,
1221
+ mode,
1222
+ state.profile,
1223
+ nativeContextLimits(latest),
1224
+ );
1225
+ if (!result.written) return { response: jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500) };
1226
+ const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint);
1227
+ const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`;
1228
+ // The durable marker describes the committed gateway even if old-mode cleanup fails.
1229
+ const firstPartyRemoved = removeDesktopFirstParty();
1230
+ if (!firstPartyRemoved.ok) {
1231
+ return { response: jsonResponse({
1232
+ error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`,
1233
+ code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason,
1234
+ applied: true, saved: committed.ok, mode: "gateway", path: result.path,
1235
+ ...(modeWarning ? { warning: modeWarning } : {}),
1236
+ }, 500) };
1237
+ }
1238
+ return { result, committed, modeWarning, pickerOff };
1239
+ });
1240
+ if (outcome.response) return outcome.response;
1241
+ const { result, committed, modeWarning, pickerOff } = outcome as Exclude<typeof outcome, { response: Response }>;
1214
1242
  const { claudeDesktopPolicyWarning, getCachedClaudeDesktopPolicy } = await import("../../claude/desktop-policy");
1215
1243
  const policyState = deps.probeClaudeDesktopPolicy
1216
1244
  ? await deps.probeClaudeDesktopPolicy({ platform: deps.platform ?? process.platform })
1217
1245
  : await getCachedClaudeDesktopPolicy({ platform: deps.platform ?? process.platform });
1218
1246
  const policyWarning = claudeDesktopPolicyWarning(policyState);
1219
- const warning = [modeWarning, policyWarning].filter(Boolean).join(" ");
1247
+ const pickerWarning = pickerOff.residual?.length
1248
+ ? `Picker mode cleanup is incomplete (${pickerOff.residual.join(", ")}); run ocx claude desktop picker off.`
1249
+ : undefined;
1250
+ const warning = [modeWarning, policyWarning, pickerWarning].filter(Boolean).join(" ");
1220
1251
  return jsonResponse({
1221
1252
  ok: true,
1222
1253
  mode: "gateway",
@@ -1238,15 +1269,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1238
1269
  try {
1239
1270
  const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
1240
1271
  const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p");
1241
- const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party");
1272
+ const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty, observeClaudeDesktopMode } = await import("../../claude/desktop-first-party");
1242
1273
  const { getClaudeInterceptState } = await import("../../claude/intercept/runtime");
1274
+ const { DESKTOP_PICKER_ID_SUGGESTIONS, readInterceptBindings } = await import("../../claude/intercept/model-bindings");
1243
1275
  const persisted = loadConfig();
1244
1276
  const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null;
1245
1277
  const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint });
1246
1278
  const desiredEnabled = claudeDesktopIntegrationEnabled(persisted);
1247
1279
  const gatewayApplied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted";
1248
1280
  // A gateway profile on disk is what Desktop actually runs, whatever the saved mode says.
1249
- const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted);
1281
+ const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted, observeClaudeDesktopMode(persisted));
1250
1282
  const firstPartySeen = inspectDesktopFirstParty(persisted);
1251
1283
  const intercept = getClaudeInterceptState();
1252
1284
  const firstParty = {
@@ -1257,6 +1289,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1257
1289
  interceptRunning: intercept !== null,
1258
1290
  proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort,
1259
1291
  caCertPath: firstPartySeen.caCertPath,
1292
+ // What the running proxy routes with right now (live config), not the file on disk.
1293
+ modelBindings: readInterceptBindings(config.claudeCode),
1294
+ pickerSuggestions: [...DESKTOP_PICKER_ID_SUGGESTIONS],
1295
+ // Picker mode: the controller's view while it runs, else the static offline status.
1296
+ picker: await (await import("./claude-desktop-picker-routes")).pickerStatusFor(persisted),
1260
1297
  };
1261
1298
  const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied;
1262
1299
  // "Needs update" is only meaningful while the integration is wanted. When the
@@ -1290,6 +1327,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1290
1327
  return jsonResponse({
1291
1328
  desiredEnabled,
1292
1329
  mode,
1330
+ // Shown wherever first-party is selected or its env is still applied (account-risk notice).
1331
+ riskWarning: mode === "first-party" || firstPartySeen.applied || firstPartySeen.stale
1332
+ ? (await import("../../claude/desktop-risk")).firstPartyAccountRisk()
1333
+ : null,
1293
1334
  firstParty,
1294
1335
  installed: observed.kind !== "not_installed",
1295
1336
  observedKind: observed.kind,
@@ -1312,6 +1353,53 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1312
1353
  }
1313
1354
  }
1314
1355
 
1356
+ // First-party model bindings: Claude Desktop Code tab picker id -> opencodex route, honoured
1357
+ // only on the claude-intercept ingress (src/claude/intercept/model-bindings.ts).
1358
+ if (url.pathname === "/api/claude-desktop/first-party-bindings" && req.method === "PUT") {
1359
+ let body: unknown;
1360
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
1361
+ try {
1362
+ const { applyInterceptBindingPatch, parseInterceptBindingPatch, readInterceptBindings } = await import("../../claude/intercept/model-bindings");
1363
+ const patch = parseInterceptBindingPatch(body);
1364
+ if ("error" in patch) return jsonResponse({ error: patch.error }, 400);
1365
+ // Same route vocabulary the Desktop profile and the dashboard use, native routes included.
1366
+ const state = await buildClaudeDesktopState(config);
1367
+ const availableRoutes = new Set(state.models.filter(model => model.available).map(model => model.route));
1368
+ let rejection: string | null = null;
1369
+ const outcome = mutatePersistedConfig<OcxConfig["claudeCode"]>(persisted => {
1370
+ const current = readInterceptBindings(persisted.claudeCode);
1371
+ const next = applyInterceptBindingPatch(current, patch, availableRoutes);
1372
+ if (!next.ok) {
1373
+ rejection = next.error;
1374
+ return { changed: false, value: persisted.claudeCode };
1375
+ }
1376
+ if (!next.changed) return { changed: false, value: structuredClone(persisted.claudeCode) };
1377
+ const claudeCode = { ...(persisted.claudeCode ?? {}) };
1378
+ const intercept = { ...(claudeCode.intercept ?? {}) };
1379
+ if (Object.keys(next.bindings).length > 0) intercept.modelMap = next.bindings;
1380
+ else delete intercept.modelMap;
1381
+ if (Object.keys(intercept).length > 0) claudeCode.intercept = intercept;
1382
+ else delete claudeCode.intercept;
1383
+ persisted.claudeCode = claudeCode;
1384
+ return { changed: true, value: structuredClone(persisted.claudeCode) };
1385
+ });
1386
+ if (rejection) return jsonResponse({ error: rejection }, 400);
1387
+ if (outcome.status === "unavailable") {
1388
+ return jsonResponse({ error: `First-party bindings could not be saved (config ${outcome.reason})` }, outcome.reason === "conflict" ? 409 : 500);
1389
+ }
1390
+ adoptPersistedClaudeCode(config, outcome.value);
1391
+ // Pin the committed leaf: an unarmed live snapshot may otherwise keep its old intercept block.
1392
+ const committedIntercept = outcome.value?.intercept;
1393
+ const liveClaudeCode = { ...(config.claudeCode ?? {}) };
1394
+ if (committedIntercept) liveClaudeCode.intercept = structuredClone(committedIntercept);
1395
+ else delete liveClaudeCode.intercept;
1396
+ config.claudeCode = liveClaudeCode;
1397
+ return jsonResponse({ ok: true, modelBindings: readInterceptBindings(config.claudeCode) });
1398
+ } catch (error) {
1399
+ return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
1400
+ }
1401
+ }
1402
+
1315
1403
  // Claude Code inbound settings (GUI "Claude ON" toggle + Claude page).
1316
1404
  if (url.pathname === "/api/claude-code" && req.method === "GET") {
1317
1405
  const models = await fetchAllModels(config);
@@ -1666,8 +1754,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1666
1754
  const warnings: string[] = [];
1667
1755
  // authMode changes must reconcile the injected system env too: switching back to
1668
1756
  // Subscription has to remove the opencodex-owned dummy ANTHROPIC_AUTH_TOKEN
1669
- // (audit R1 blocker #1/#2, devlog 260720_claude_authmode_persist).
1670
- if (body.systemEnv !== undefined || body.authMode !== undefined) {
1757
+ // (audit R1 blocker #1/#2, devlog 260720_claude_authmode_persist). Model slots and
1758
+ // levers feed the same injection, so a changed or cleared slot must not linger in
1759
+ // launchd until the next restart.
1760
+ const systemEnvInputs = ["systemEnv", "authMode", "model", "smallFastModel", "tierModels",
1761
+ "maxContextTokens", "alwaysEnableEffort", "autoContext", "autoCompactWindow"] as const;
1762
+ if (systemEnvInputs.some(field => body[field] !== undefined)) {
1671
1763
  try {
1672
1764
  await applySystemEnvToggle(config, config.port);
1673
1765
  } catch (err) {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Claude Desktop picker mode over the management API, and the transition helpers every Desktop
3
+ * mode change uses.
4
+ *
5
+ * While this server runs a picker controller (src/claude/desktop-picker.ts), every picker
6
+ * mutation goes through it, serialized by its lock. With no controller (intercept disabled, client
7
+ * role, a failed bind), `runPickerTransition` runs the same code with offline ops that only remove
8
+ * leftover picker artifacts, and enabling reports `proxy_unavailable`.
9
+ */
10
+ import type { OcxConfig } from "../../types";
11
+ import type { DesktopPickerController, DesktopPickerOps, DesktopPickerStatus } from "../../claude/desktop-picker";
12
+ import { loadConfig } from "../../config";
13
+ import { jsonResponse } from "../auth-cors";
14
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
15
+ import type { ManagementContext } from "./context";
16
+ import { isPlainRecord } from "./shared";
17
+
18
+ export async function currentPickerController(): Promise<DesktopPickerController | null> {
19
+ const { getClaudePickerController } = await import("../../claude/intercept/runtime");
20
+ return getClaudePickerController();
21
+ }
22
+
23
+ /** Picker status for status payloads: the controller's, or the static offline status. */
24
+ export async function pickerStatusFor(config: OcxConfig): Promise<DesktopPickerStatus> {
25
+ const controller = await currentPickerController();
26
+ if (controller) return controller.status();
27
+ const { offlinePickerStatus } = await import("../../claude/desktop-picker");
28
+ return offlinePickerStatus(config);
29
+ }
30
+
31
+ /** Run a Desktop mode change under the picker lock (or with offline ops when no controller runs). */
32
+ export async function runPickerTransition<T>(config: OcxConfig, fn: (ops: DesktopPickerOps) => Promise<T>): Promise<T> {
33
+ const [controller, { runDesktopTransition }] = await Promise.all([
34
+ currentPickerController(),
35
+ import("../../claude/desktop-picker"),
36
+ ]);
37
+ return runDesktopTransition(controller, fn, { config });
38
+ }
39
+
40
+ /** First-party turns the picker on unless the operator said `claudeCode.intercept.picker: false`. */
41
+ export function pickerPreferenceOn(config: Pick<OcxConfig, "claudeCode">): boolean {
42
+ return config.claudeCode?.intercept?.picker !== false;
43
+ }
44
+
45
+ const PICKER_BODY_KEYS = new Set(["enabled", "persist", "trustedLocally", "callerAddedTrust"]);
46
+ /** Enable outcomes that are progress, not refusal: on, waiting for a Desktop restart, or for the keychain step. */
47
+ const ENABLE_ACCEPTED = new Set(["active", "restart_required", "trust_pending"]);
48
+
49
+ interface PickerRequestBody { enabled: boolean; persist: boolean; trustedLocally?: boolean; callerAddedTrust?: boolean }
50
+
51
+ function parsePickerBody(raw: unknown): PickerRequestBody | string {
52
+ if (!isPlainRecord(raw)) return "JSON body must be an object";
53
+ for (const key of Object.keys(raw)) if (!PICKER_BODY_KEYS.has(key)) return `unknown field: ${key}`;
54
+ if (typeof raw.enabled !== "boolean") return "enabled must be a boolean";
55
+ if (typeof raw.persist !== "boolean") return "persist must be a boolean";
56
+ for (const key of ["trustedLocally", "callerAddedTrust"] as const) {
57
+ if (raw[key] !== undefined && typeof raw[key] !== "boolean") return `${key} must be a boolean`;
58
+ }
59
+ return raw as unknown as PickerRequestBody;
60
+ }
61
+
62
+ export async function handleClaudeDesktopPickerRoutes(ctx: ManagementContext): Promise<Response | null> {
63
+ const { req, url } = ctx;
64
+
65
+ if (url.pathname === "/api/claude-desktop/picker" && req.method === "GET") {
66
+ return jsonResponse({ ok: true, picker: await pickerStatusFor(loadConfig()) });
67
+ }
68
+
69
+ if (url.pathname === "/api/claude-desktop/picker" && req.method === "PUT") {
70
+ let raw: unknown;
71
+ try {
72
+ raw = await readManagementJsonBody(req);
73
+ } catch (error) {
74
+ rethrowManagementBodyTooLarge(error);
75
+ return jsonResponse({ error: "invalid JSON body" }, 400);
76
+ }
77
+ const body = parsePickerBody(raw);
78
+ if (typeof body === "string") return jsonResponse({ error: body }, 400);
79
+ const controller = await currentPickerController();
80
+ if (body.enabled) {
81
+ if (!controller) {
82
+ const { offlinePickerStatus } = await import("../../claude/desktop-picker");
83
+ return jsonResponse({
84
+ ok: false,
85
+ code: "picker_proxy_unavailable",
86
+ error: "Picker mode needs the Claude Desktop picker proxy, which is not running in this server.",
87
+ picker: offlinePickerStatus(loadConfig()),
88
+ }, 503);
89
+ }
90
+ const picker = await controller.enable({
91
+ persist: body.persist,
92
+ context: body.trustedLocally ? "cli-trusted" : "server",
93
+ ...(body.callerAddedTrust !== undefined ? { callerAddedTrust: body.callerAddedTrust } : {}),
94
+ });
95
+ if (ENABLE_ACCEPTED.has(picker.reason) && !picker.residual?.length) return jsonResponse({ ok: true, picker });
96
+ return jsonResponse({
97
+ ok: false,
98
+ code: "picker_enable_refused",
99
+ reason: picker.reason,
100
+ error: `Picker mode was not enabled (${picker.reason}).`,
101
+ picker,
102
+ }, 409);
103
+ }
104
+ if (controller) {
105
+ const picker = await controller.disable({ persist: body.persist });
106
+ if (!picker.residual?.length) return jsonResponse({ ok: true, picker });
107
+ return jsonResponse({
108
+ ok: false,
109
+ code: "picker_disable_incomplete",
110
+ error: `Picker mode cleanup is incomplete (${picker.residual.join(", ")}).`,
111
+ picker,
112
+ }, 500);
113
+ }
114
+ // No controller: nothing can terminate claude.ai here, so cleanup is local.
115
+ if (body.persist) {
116
+ const { createPickerPreferenceWriter } = await import("../../claude/intercept/runtime");
117
+ if (!(await createPickerPreferenceWriter(ctx.config))(false)) {
118
+ return jsonResponse({ ok: false, error: "The picker preference could not be saved; nothing was removed." }, 500);
119
+ }
120
+ }
121
+ const { offlinePickerStatus, removeDesktopPickerArtifacts } = await import("../../claude/desktop-picker");
122
+ const removed = await removeDesktopPickerArtifacts({});
123
+ const picker = { ...offlinePickerStatus(loadConfig()), ...(removed.residual?.length ? { residual: removed.residual } : {}) };
124
+ return jsonResponse({ ok: removed.ok, picker }, removed.ok ? 200 : 500);
125
+ }
126
+
127
+ return null;
128
+ }