@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
package/src/router.ts CHANGED
@@ -89,6 +89,12 @@ export interface RouteResult {
89
89
  combo?: ComboPick;
90
90
  /** Bounded route-decision trace (RI-01); never contains secrets. */
91
91
  routeDecision?: RouteDecisionTraceV1;
92
+ /**
93
+ * Full eligible provider/model membership from a policy evaluation, keyed
94
+ * "provider\u0000model". The trace's candidate list is bounded; fallback
95
+ * redirect checks need membership for eligible rows that were truncated out.
96
+ */
97
+ policyEligibility?: ReadonlySet<string>;
92
98
  /**
93
99
  * Set when a blocked-model redirect moved the request to a different
94
100
  * provider. Caller credentials addressed to the source route must not follow
@@ -733,6 +739,9 @@ function routeModelInternal(
733
739
  ...routed,
734
740
  routeKind: "policy" as const,
735
741
  routeReason: routed.routeReason === "blocked-model-redirect" ? "blocked-model-redirect" : "policy-selected",
742
+ policyEligibility: new Set(evaluation.candidates
743
+ .filter(candidate => candidate.eligible)
744
+ .map(candidate => candidate.provider + "\u0000" + candidate.model)),
736
745
  routeDecision: {
737
746
  ...evaluation.trace,
738
747
  selected: {
@@ -0,0 +1,7 @@
1
+ export function anthropicRateLimitHeaders(upstream: Headers): Record<string, string> {
2
+ const headers: Record<string, string> = {};
3
+ upstream.forEach((value, name) => {
4
+ if (name.startsWith("anthropic-ratelimit-")) headers[name] = value;
5
+ });
6
+ return headers;
7
+ }
@@ -32,6 +32,7 @@ import {
32
32
  } from "../config/provider-validation";
33
33
  import { providerDestinationConfigError } from "../lib/destination-policy";
34
34
  import { providerEgressConfigError } from "../lib/provider-egress";
35
+ import { providerTlsProfileConfigError } from "../lib/provider-tls-profile";
35
36
  import { redactSecretString } from "../lib/redact";
36
37
  import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
37
38
  import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
@@ -780,6 +781,11 @@ export function providerManagementConfigError(
780
781
  return `provider ${name} must not include codexAccountMode`;
781
782
  }
782
783
  const typed = provider as unknown as OcxProviderConfig;
784
+ // Every write path (POST, PUT, reload, both PATCH passes) funnels through here, so a PATCH
785
+ // that changes authMode/baseUrl/adapter under a retained tlsProfile is refused before it
786
+ // persists a row the config schema would later reject as document-fatal.
787
+ const tlsProfileError = providerTlsProfileConfigError(name, typed);
788
+ if (tlsProfileError) return `provider ${JSON.stringify(redactSecretString(name))} ${tlsProfileError}`;
783
789
  const baseUrlError = providerBaseUrlConfigError(typed.baseUrl);
784
790
  if (baseUrlError) return `provider ${name} ${baseUrlError}`;
785
791
  if (effectiveGoogleMode(name, typed) === "vertex" && typed.location !== undefined) {
@@ -1110,6 +1116,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1110
1116
  desktopExecutor: "redacted",
1111
1117
  unsafeAllowNativeLocalExec: "editor",
1112
1118
  nativeLocalExec: "editor",
1119
+ tlsProfile: "editor",
1113
1120
  } as const satisfies Record<keyof OcxProviderConfig, ProviderConfigFieldPolicy>;
1114
1121
 
1115
1122
  type ProviderFieldWithPolicy<Policy extends ProviderConfigFieldPolicy> = {
@@ -53,6 +53,7 @@ import { clientWireLogOf, clientWireOf } from "./inference/client-wire";
53
53
  import { directEncodersApply } from "./inference/client-encoder-delivery";
54
54
  import { responseWithDeferredRequestLog } from "./relay";
55
55
  import { handleResponses } from "./responses";
56
+ import { previewXaiOauthWireModel } from "./responses/core-normalize";
56
57
  import { providerConsumesCallerAuthorization } from "../providers/caller-authorization";
57
58
  import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar";
58
59
  import { captureCallerDirectAuth } from "../providers/caller-authorization";
@@ -177,16 +178,20 @@ async function handleChatCompletionsWithBudget(
177
178
  let nativeDecline: ProtocolReasonCode | undefined = "unknown-model";
178
179
  try {
179
180
  const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody));
180
- // The native Chat lane sends without re-entering the Responses path, so it
181
- // has to apply the key's scope itself. Translated traffic is checked where
182
- // every rewrite converges instead.
183
- assertRouteAllowedByScope(resolveAdmissionModelScope(config, logIds?.admission), requestedModel, route);
184
181
  // Preserve the routed destination for Go recognition, then settle the wire before
185
182
  // deriving protocol-scoped affinity. Recognition must not inspect the flipped adapter.
186
183
  const routedProvider = route.provider;
187
184
  route.staticPolicy = captureRouteStaticPolicy(
188
185
  route.providerName, route.modelId, routedProvider, route.staticPolicy.effectiveAlias, "chat",
189
186
  );
187
+ // Native Chat must check its own destination; translated xAI OAuth turns
188
+ // preview the billed Fast lane before their final Responses scope check.
189
+ assertRouteAllowedByScope(resolveAdmissionModelScope(config, logIds?.admission), requestedModel, {
190
+ providerName: route.providerName,
191
+ modelId: previewXaiOauthWireModel({ options: {
192
+ serviceTier: typeof chatBody.service_tier === "string" ? chatBody.service_tier : undefined,
193
+ } }, route, config, "chat"),
194
+ });
190
195
  const wireProvider = resolveWireProtocolOverride(route.providerName, route.modelId, routedProvider, "chat", route.staticPolicy);
191
196
  route.provider = resolveOpenCodeGoTransport(
192
197
  wireProvider,
@@ -28,6 +28,7 @@ import { recordDesktopRequest } from "../claude/desktop-health";
28
28
  import { stripOneMillionMarker } from "../claude/context-windows";
29
29
  import { captureClaudeInbound } from "../claude/inbound-debug";
30
30
  import { claudeCodeForIngress } from "../claude/intercept/model-bindings";
31
+ import { classifyInterceptClient } from "../claude/intercept/client-class";
31
32
  import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
32
33
  import { carriesMessageThread, messageThreadUnsupportedResponse } from "../claude/message-threads";
33
34
  import {
@@ -39,6 +40,7 @@ import {
39
40
  UPSTREAM_RESET_REPLAY_REFUSED_CODE,
40
41
  } from "../lib/upstream-retry";
41
42
  import { resolveClientRetryAfter } from "../lib/retry-after";
43
+ import { anthropicRateLimitHeaders } from "./anthropic-rate-limit-headers";
42
44
  import {
43
45
  anthropicErrorBody,
44
46
  anthropicErrorResponse,
@@ -72,6 +74,7 @@ import { clientWireOf } from "./inference/client-wire";
72
74
  import { directEncodersApply } from "./inference/client-encoder-delivery";
73
75
  import type { ClientEncoderOption } from "./responses/core-options";
74
76
  import { handleResponses } from "./responses";
77
+ import { previewXaiOauthWireModel } from "./responses/core-normalize";
75
78
  import { upstreamWireForAdapter } from "../protocols/contract";
76
79
  import { createProtocolEnvelope, type ProtocolEnvelope } from "../protocols/envelope";
77
80
  import { featuresFromMessagesBody, type ProtocolFeature } from "../protocols/features";
@@ -82,6 +85,7 @@ import { requestPathForLane } from "../protocols/path";
82
85
  import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings";
83
86
  import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace";
84
87
  import { recordProtocolShadowPlan } from "../protocols/shadow-plan";
88
+ import { captureAnthropicClientIdentity } from "../adapters/anthropic/client-identity";
85
89
  import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility";
86
90
  import {
87
91
  isApiAuthRequired,
@@ -296,7 +300,7 @@ export interface PassthroughBodyGuard {
296
300
  }
297
301
 
298
302
  type PassthroughCloseReason = "terminal" | "client_cancel" | "body_stall" | "body_overflow";
299
- type PassthroughFinalizeMeta = { closeReason: PassthroughCloseReason; terminalStatus?: "failed" };
303
+ type PassthroughFinalizeMeta = { closeReason: PassthroughCloseReason; terminalStatus?: "failed" | "incomplete" };
300
304
 
301
305
  /**
302
306
  * Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal),
@@ -340,6 +344,11 @@ export function tapAnthropicSseForLog(
340
344
  };
341
345
  const inspect = (chunk: Uint8Array) => {
342
346
  buffer += decoder.decode(chunk, { stream: true });
347
+ // SSE lines may end in CRLF, LF or CR. Normalize the inspection copy to LF (the forwarded
348
+ // bytes are untouched), holding a trailing CR until the next chunk shows whether an LF
349
+ // follows it, so a CRLF split across chunks stays one line ending.
350
+ const heldCr = buffer.endsWith("\r");
351
+ buffer = (heldCr ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n") + (heldCr ? "\r" : "");
343
352
  let sep: number;
344
353
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
345
354
  const frame = buffer.slice(0, sep);
@@ -347,6 +356,17 @@ export function tapAnthropicSseForLog(
347
356
  inspectFrame(frame);
348
357
  }
349
358
  };
359
+ // The last block can sit in the buffer without its blank line: an upstream that stopped after
360
+ // it, or a held trailing CR. Count it before deciding how the turn ended (the Responses relay
361
+ // flushes the same candidate). Returns true when this flush found the terminal in a block the
362
+ // client has not seen a blank line after, so the caller restores one.
363
+ const flushTail = (): boolean => {
364
+ const terminalBefore = terminalSeen;
365
+ const tail = (buffer + decoder.decode()).replace(/\r\n?/g, "\n");
366
+ buffer = "";
367
+ if (tail) inspectFrame(tail);
368
+ return terminalSeen && !terminalBefore && !tail.endsWith("\n\n");
369
+ };
350
370
  const reader = upstream.getReader();
351
371
  let settled = false;
352
372
  let bodyBytes = 0;
@@ -369,9 +389,25 @@ export function tapAnthropicSseForLog(
369
389
  settled = true;
370
390
  idle.cancel();
371
391
  detachAbort();
392
+ const terminalInTail = flushTail();
372
393
  recordUsage();
373
- finalize(200, { closeReason });
374
- closeWithErrorFrame(errType, message);
394
+ if (terminalSeen) {
395
+ // The turn already ended (message_stop or an upstream error event): an upstream that then
396
+ // idles or keeps sending did not cut it short. Same rule as the read-error branch,
397
+ // including the restored blank line for a terminal found only in the tail.
398
+ finalize(200, { closeReason: "terminal" });
399
+ try {
400
+ if (terminalInTail) tapController?.enqueue(encoder.encode("\n\n"));
401
+ tapController?.close();
402
+ } catch { /* client already torn down */ }
403
+ } else {
404
+ // A cut-short turn, logged as the Responses relay logs a stall-timeout incomplete
405
+ // (httpStatusForRequestLogTerminal: only a max_output_tokens incomplete is a 200). A 200
406
+ // row with no terminalStatus also lost its failure diagnostics in usage.jsonl.
407
+ logCtx.upstreamError = message.slice(0, 500);
408
+ finalize(502, { terminalStatus: "incomplete", closeReason });
409
+ closeWithErrorFrame(errType, message);
410
+ }
375
411
  reader.cancel(new DOMException(message, closeReason === "body_stall" ? "TimeoutError" : "QuotaExceededError")).catch(() => {});
376
412
  };
377
413
  const idle = idleDeadline(guard?.stallMs ?? 0, () => {
@@ -450,12 +486,7 @@ export function tapAnthropicSseForLog(
450
486
  settled = true;
451
487
  idle.cancel();
452
488
  detachAbort();
453
- // A read error can follow the last SSE block before its blank-line delimiter. Count
454
- // that block before deciding how the turn ended, as the Responses relay does.
455
- const terminalBeforeTail = terminalSeen;
456
- const tail = buffer + decoder.decode();
457
- buffer = "";
458
- if (tail) inspectFrame(tail);
489
+ const terminalInTail = flushTail();
459
490
  recordUsage();
460
491
  if (isTranslatorBudgetExceededError(err)) {
461
492
  // A local cap, not an upstream failure: the non-streaming native Messages fold
@@ -471,7 +502,7 @@ export function tapAnthropicSseForLog(
471
502
  try {
472
503
  // An SSE parser drops an event that EOF cuts off before its blank line, so restore
473
504
  // the delimiter when the terminal was only found in that unterminated tail.
474
- if (!terminalBeforeTail) controller.enqueue(encoder.encode("\n\n"));
505
+ if (terminalInTail) controller.enqueue(encoder.encode("\n\n"));
475
506
  controller.close();
476
507
  } catch { /* torn down */ }
477
508
  reader.cancel(err).catch(() => {});
@@ -598,11 +629,13 @@ async function anthropicNativePassthrough(
598
629
  const upstream = result.upstream;
599
630
 
600
631
  const contentType = upstream.headers.get("content-type") ?? "application/json";
632
+ const rateLimitHeaders = anthropicRateLimitHeaders(upstream.headers);
601
633
  const bodyGuard = resolvePassthroughBodyGuard(config, req.signal);
602
634
  if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) {
603
635
  return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize, bodyGuard), {
604
636
  status: upstream.status,
605
637
  headers: {
638
+ ...rateLimitHeaders,
606
639
  "Content-Type": contentType,
607
640
  "Cache-Control": "no-cache",
608
641
  "Connection": "keep-alive",
@@ -647,7 +680,7 @@ async function anthropicNativePassthrough(
647
680
  const retryAfter = upstream.headers.get("retry-after");
648
681
  return new Response(text, {
649
682
  status: upstream.status,
650
- headers: { "Content-Type": contentType, ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
683
+ headers: { ...rateLimitHeaders, "Content-Type": contentType, ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
651
684
  });
652
685
  }
653
686
 
@@ -891,8 +924,10 @@ async function handleClaudeMessagesWithBudget(
891
924
  req.headers.get("anthropic-beta") ?? undefined,
892
925
  );
893
926
  // Client surface discrimination: Desktop 3P aliases resolve through the
894
- // desktop registry; Code uses readable aliases or direct model names.
895
- if (isRec(anthropicBody) && typeof anthropicBody.model === "string" && resolveDesktop3pAlias(anthropicBody.model)) {
927
+ // desktop registry; Code uses readable aliases or direct model names. The CLI's first-party
928
+ // picker also offers registry aliases, so a CLI-classified User-Agent stays the Code surface.
929
+ if (isRec(anthropicBody) && typeof anthropicBody.model === "string" && resolveDesktop3pAlias(anthropicBody.model)
930
+ && classifyInterceptClient(req.headers.get("user-agent")) !== "cli") {
896
931
  logCtx.surface = "claude-desktop";
897
932
  recordDesktopRequest();
898
933
  }
@@ -1046,18 +1081,20 @@ async function handleClaudeMessagesWithBudget(
1046
1081
  };
1047
1082
  try {
1048
1083
  const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
1049
- // Same reason as the native Chat lane: this route can be sent from here, so
1050
- // the key's scope is applied before the wire is settled.
1051
- assertRouteAllowedByScope(
1052
- resolveAdmissionModelScope(config, logIds?.admission),
1053
- String(internalBody.model ?? ""),
1054
- route,
1055
- );
1056
1084
  // Settle the wire once so the sampling decision below reads the effective
1057
1085
  // adapter rather than the provider-wide default (#404).
1058
1086
  route.staticPolicy = captureRouteStaticPolicy(
1059
1087
  route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, "anthropic",
1060
1088
  );
1089
+ // Keep native dispatch scoped while translated xAI OAuth requests preview
1090
+ // the same billed Fast lane as their final Responses scope check.
1091
+ assertRouteAllowedByScope(
1092
+ resolveAdmissionModelScope(config, logIds?.admission),
1093
+ String(internalBody.model ?? ""),
1094
+ { providerName: route.providerName, modelId: previewXaiOauthWireModel({ options: {
1095
+ serviceTier: typeof internalBody.service_tier === "string" ? internalBody.service_tier : undefined,
1096
+ } }, route, config, "anthropic") },
1097
+ );
1061
1098
  route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy);
1062
1099
  logCtx.routeDecision = route.routeDecision;
1063
1100
  settledRoute = route;
@@ -1166,7 +1203,8 @@ async function handleClaudeMessagesWithBudget(
1166
1203
  return await handleNativeMessages({
1167
1204
  req, config, logCtx, ...(logIds ? { logIds } : {}),
1168
1205
  route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector,
1169
- // The one caller header the native lane is given; the builder allowlists it.
1206
+ // Compatibility identity is an opaque request-local handle, separate from credentials.
1207
+ clientIdentity: captureAnthropicClientIdentity(req.headers),
1170
1208
  callerAnthropicBeta: req.headers.get("anthropic-beta"),
1171
1209
  });
1172
1210
  }
@@ -8,6 +8,8 @@ import {
8
8
  startClaudeIntercept,
9
9
  type ClaudeInterceptHandle,
10
10
  type StartClaudeInterceptOptions,
11
+ type ClaudeInterceptOutcome,
12
+ ClaudeInterceptProxyBindError,
11
13
  } from "../../claude/intercept/runtime";
12
14
 
13
15
  /**
@@ -20,6 +22,8 @@ export interface ClaudeInterceptLifecycle<T> {
20
22
  /** True once the TLS listener has bound and `requestServer` is it. */
21
23
  ownsListener(requestServer: Server<T>): boolean;
22
24
  start(options: StartClaudeInterceptOptions<T>): void;
25
+ ensure(): Promise<ClaudeInterceptOutcome>;
26
+ lastOutcome(): ClaudeInterceptOutcome | null;
23
27
  stop(): Promise<void>;
24
28
  }
25
29
 
@@ -53,34 +57,76 @@ export async function loadPickerRoutesFromCatalog(): Promise<PickerRouteInput> {
53
57
 
54
58
  export function createClaudeInterceptLifecycle<T>(): ClaudeInterceptLifecycle<T> {
55
59
  let listener: Server<T> | null = null;
56
- let pending: Promise<ClaudeInterceptHandle<T> | null> = Promise.resolve(null);
60
+ let options: StartClaudeInterceptOptions<T> | undefined;
61
+ let handle: ClaudeInterceptHandle<T> | null = null;
62
+ let inflight: Promise<ClaudeInterceptOutcome> | undefined;
63
+ let stopped = false;
64
+ let startupPending = false;
65
+ let retryAfterStartup = false;
66
+ let observed: ClaudeDesktopModeObservation | undefined;
67
+ const stateOf = (bound: ClaudeInterceptHandle<T>) => ({ proxyPort: bound.proxyPort, caCertPath: bound.caCertPath, pickerProxyPort: bound.pickerProxyPort, pickerReason: bound.pickerReason ?? null, pickerFailurePort: bound.pickerFailurePort });
68
+ let outcome: ClaudeInterceptOutcome | null = null;
69
+ const ensure = (): Promise<ClaudeInterceptOutcome> => {
70
+ if (stopped) return Promise.resolve(outcome = { ok: false, reason: "stopped" });
71
+ if (inflight) {
72
+ if (startupPending) retryAfterStartup = true;
73
+ return inflight;
74
+ }
75
+ if (handle) {
76
+ if (options) observed = observeClaudeDesktopMode(options.config);
77
+ return Promise.resolve(outcome = { ok: true, state: stateOf(handle) });
78
+ }
79
+ if (!options) return Promise.resolve(outcome = { ok: false, reason: "failed" });
80
+ const opts = options;
81
+ const run = async (): Promise<ClaudeInterceptOutcome> => {
82
+ if (opts.config.runtimeRole === "client") return { ok: false, reason: "client_role" };
83
+ if (!claudeInterceptEnabled(opts.config)) return { ok: false, reason: "disabled" };
84
+ if (opts.requestedPort === 0 && typeof opts.config.claudeCode?.intercept?.port !== "number")
85
+ return { ok: false, reason: "ephemeral_port" };
86
+ try {
87
+ observed = observeClaudeDesktopMode(opts.config);
88
+ handle = await startClaudeIntercept<T>({
89
+ ...opts,
90
+ desiredClients: () => buildInterceptDesiredClients(opts.config, observed!)(),
91
+ loadPickerRoutes: opts.loadPickerRoutes ?? loadPickerRoutesFromCatalog,
92
+ dispatch: (req, requestServer) => {
93
+ listener ??= requestServer;
94
+ return opts.dispatch(req, requestServer);
95
+ },
96
+ });
97
+ if (!handle) return { ok: false, reason: "disabled" };
98
+ listener = handle.listener;
99
+ console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`);
100
+ return { ok: true, state: stateOf(handle) };
101
+ } catch (error) {
102
+ listener = null;
103
+ console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
104
+ return error instanceof ClaudeInterceptProxyBindError && error.code === "EADDRINUSE"
105
+ ? { ok: false, reason: "port_in_use", port: error.port }
106
+ : { ok: false, reason: "failed" };
107
+ }
108
+ };
109
+ inflight = run().then(async result => {
110
+ // An ensure arriving during startup first joins that attempt, then retries its null outcome.
111
+ if (!result.ok && retryAfterStartup && !stopped) return run();
112
+ return result;
113
+ }).then(result => outcome = result).finally(() => {
114
+ inflight = undefined; startupPending = false; retryAfterStartup = false;
115
+ });
116
+ return inflight;
117
+ };
57
118
  return {
58
119
  ownsListener: requestServer => listener !== null && requestServer === listener,
59
- start(options) {
60
- const dispatch = options.dispatch;
61
- const observed = observeClaudeDesktopMode(options.config);
62
- pending = startClaudeIntercept<T>({
63
- ...options,
64
- // A disabled Claude surface relays everything while the bound listener lives until restart.
65
- desiredClients: buildInterceptDesiredClients(options.config, observed),
66
- loadPickerRoutes: options.loadPickerRoutes ?? loadPickerRoutesFromCatalog,
67
- dispatch: (req, requestServer) => {
68
- listener ??= requestServer;
69
- return dispatch(req, requestServer);
70
- },
71
- }).then(handle => {
72
- if (handle) {
73
- listener = handle.listener;
74
- console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`);
75
- }
76
- return handle;
77
- }).catch((error: unknown) => {
78
- console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
79
- return null;
80
- });
81
- },
120
+ start(opts) { options = opts; startupPending = true; void ensure(); },
121
+ ensure,
122
+ lastOutcome: () => outcome,
82
123
  async stop() {
83
- await (await pending)?.stop();
124
+ stopped = true;
125
+ await inflight;
126
+ await handle?.stop();
127
+ handle = null;
128
+ listener = null;
129
+ outcome = { ok: false, reason: "stopped" };
84
130
  },
85
131
  };
86
132
  }
@@ -28,6 +28,8 @@ export interface OptionalListenerStartContext<T> {
28
28
  }
29
29
 
30
30
  export interface OptionalListenerSet<T> {
31
+ ensureClaudeIntercept(): ReturnType<ClaudeInterceptLifecycle<T>["ensure"]>;
32
+ claudeInterceptOutcome(): ReturnType<ClaudeInterceptLifecycle<T>["lastOutcome"]>;
31
33
  ingressOf(server: Server<T>): ServerIngress | undefined;
32
34
  linkRouteAllowed(url: URL, req: Request): boolean;
33
35
  linkAdmissionKeyIds(): ReadonlySet<string>;
@@ -68,6 +70,8 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
68
70
  let supervisorStop: (() => Promise<void>) | undefined;
69
71
 
70
72
  return {
73
+ ensureClaudeIntercept: () => claudeIntercept.ensure(),
74
+ claudeInterceptOutcome: () => claudeIntercept.lastOutcome(),
71
75
  ingressOf(server) {
72
76
  if (linkListener.ownsListener(server)) return "hub-link";
73
77
  if (claudeIntercept.ownsListener(server)) return "claude-intercept";
@@ -7,6 +7,7 @@ import {
7
7
  configureSharedSpendLedger,
8
8
  spendPolicyFromConfig,
9
9
  } from "../../lib/spend-reservation-ledger";
10
+ import { warnIfSyncedStateDirectory } from "../../lib/synced-state-location";
10
11
 
11
12
  const failedStartRollbacks = new WeakMap<object, Promise<void>>();
12
13
 
@@ -34,6 +35,9 @@ export interface SpendLedgerServerLifecycle {
34
35
  /** Acquire before config loading so every later startup failure has one rollback owner. */
35
36
  export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedgerServerLifecycle {
36
37
  const owner: SpendLedgerOwnerLease = acquireSpendLedgerOwner(configDir);
38
+ // Advisory: a synced state directory makes the journal's hard-link guard refuse intermittently
39
+ // (#6314). Said once at startup instead of being discovered from a 502.
40
+ warnIfSyncedStateDirectory(configDir);
37
41
  // Each entry returns whatever the listener's own stop returned. Typed as void-or-promise
38
42
  // because the rollback below has to WAIT on it: declaring it `() => void` let the call site
39
43
  // compile while statically erasing the promise it needs to await.
@@ -1,3 +1,4 @@
1
+ import { ensureManagementClaudeIntercept, interceptStartRefusal, interceptStatus } from "./claude-intercept-routes";
1
2
  import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
2
3
  import { captureDesktopAppliedMarker, commitDesktopAppliedMarker } from "../../claude/desktop-applied-marker";
3
4
  import { randomUUID } from "node:crypto";
@@ -206,6 +207,20 @@ export function setGrokApplyFlightTestHooks(
206
207
  }
207
208
  import type { ManagementContext } from "./context";
208
209
 
210
+ async function injectionModelOptions(config: OcxConfig, models: readonly CatalogModel[]) {
211
+ const disabled = new Set(config.disabledModels ?? []);
212
+ const { listCatalogNativeSlugs } = await import("../../codex/catalog");
213
+ const nativeModels = listCatalogNativeSlugs()
214
+ .filter(slug => !disabled.has(slug))
215
+ .map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
216
+ const routedModels = uniqueCatalogModelsForPublicList([...models])
217
+ .map(m => ({ provider: m.provider, model: m.id, namespaced: catalogModelSlug(m) }))
218
+ .filter(m => ![...disabled].some(stored => (
219
+ stored === m.namespaced || slugEquals(stored, m.provider, m.model)
220
+ )));
221
+ return [...nativeModels, ...routedModels];
222
+ }
223
+
209
224
  export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise<Response | null> {
210
225
  const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
211
226
 
@@ -547,18 +562,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
547
562
  // effort the prompt tells the agent to pass to spawn_agent. GET returns the current
548
563
  // picks + available models/efforts; PUT sets or clears them.
549
564
  if (url.pathname === "/api/injection-model" && req.method === "GET") {
550
- const models = await fetchAllModels(config);
551
- const disabled = new Set(config.disabledModels ?? []);
552
- const { listCatalogNativeSlugs } = await import("../../codex/catalog");
553
565
  const { CODEX_REASONING_LEVELS } = await import("../../reasoning-effort");
554
- const nativeModels = listCatalogNativeSlugs()
555
- .filter(slug => !disabled.has(slug))
556
- .map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
557
- const routedModels = uniqueCatalogModelsForPublicList(models)
558
- .map(m => ({ provider: m.provider, model: m.id, namespaced: catalogModelSlug(m) }))
559
- .filter(m => ![...disabled].some(stored => (
560
- stored === m.namespaced || slugEquals(stored, m.provider, m.model)
561
- )));
562
566
  return jsonResponse({
563
567
  multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
564
568
  syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
@@ -566,9 +570,41 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
566
570
  effort: config.injectionEffort ?? null,
567
571
  prompt: config.injectionPrompt ?? null,
568
572
  efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
569
- available: [...nativeModels, ...routedModels],
573
+ available: await injectionModelOptions(config, await fetchAllModels(config)),
570
574
  });
571
575
  }
576
+ // Read-only: sizes the described delegated work and proposes a model and effort from the
577
+ // same options GET offers. The page applies an accepted proposal through the PUT below.
578
+ if (url.pathname === "/api/injection-model/suggest" && req.method === "POST") {
579
+ let body: unknown;
580
+ try { body = await readManagementJsonBody(req); } catch (error) {
581
+ rethrowManagementBodyTooLarge(error);
582
+ return jsonResponse({ error: "invalid JSON body" }, 400);
583
+ }
584
+ const { work, model } = (body ?? {}) as { work?: unknown; model?: unknown };
585
+ const { ROLE_INSTRUCTIONS_EXCERPT_CHARS } = await import("../../codex/role-sizing");
586
+ if (typeof work !== "string" || work.trim() === "" || work.length > ROLE_INSTRUCTIONS_EXCERPT_CHARS) {
587
+ return jsonResponse({ error: `work must be a nonblank string of at most ${ROLE_INSTRUCTIONS_EXCERPT_CHARS} characters`, code: "invalid_work" }, 400);
588
+ }
589
+ if (model !== undefined && typeof model !== "string") {
590
+ return jsonResponse({ error: "model must be a string", code: "invalid_model" }, 400);
591
+ }
592
+ const { proposeDelegationModel, NoSizingModelError } = await import("./codex-role-auto-assign");
593
+ const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
594
+ try {
595
+ return jsonResponse(await proposeDelegationModel({
596
+ config,
597
+ work: work.trim(),
598
+ offered: (await injectionModelOptions(config, models)).map(option => option.namespaced),
599
+ ...(typeof model === "string" ? { sizingModel: model } : {}),
600
+ models,
601
+ ...(deps.completeCodexRoleSizing ? { completeRoleSizing: deps.completeCodexRoleSizing } : {}),
602
+ }));
603
+ } catch (error) {
604
+ if (error instanceof NoSizingModelError) return jsonResponse({ error: error.message, code: "no_sizing_model" }, 409);
605
+ throw error;
606
+ }
607
+ }
572
608
  if (url.pathname === "/api/injection-model" && req.method === "PUT") {
573
609
  let parsedBody: unknown;
574
610
  try { parsedBody = await readManagementJsonBody(req); } catch (error) {
@@ -729,20 +765,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
729
765
  // the first five eligible visible rows by display priority; OCX guidance uses natural ranks.
730
766
  if (url.pathname === "/api/subagent-models" && req.method === "GET") {
731
767
  const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
732
- const disabled = new Set(config.disabledModels ?? []);
733
768
  // Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
734
769
  // catalog, just buried by priority. List them first so the user can feature them over routed.
735
- const { listCatalogNativeSlugs } = await import("../../codex/catalog");
736
- const visibleRouted = [...new Set(models
737
- .filter(m => ![...disabled].some(stored =>
738
- stored === catalogModelSlug(m) || slugEquals(stored, m.provider, m.id)
739
- ))
740
- .map(catalogModelSlug))];
770
+ const [{ listCatalogNativeSlugs }, { subagentSelectableModels }] = await Promise.all([
771
+ import("../../codex/catalog"),
772
+ import("../../codex/subagent-selectable-models"),
773
+ ]);
741
774
  const chosen = config.subagentModels ?? [];
742
- const selectable = [
743
- ...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)),
744
- ...visibleRouted,
745
- ];
746
775
  // A saved roster slot must stay representable even after its model is disabled
747
776
  // elsewhere (Models page, provider allowlist, a provider row going away). The
748
777
  // dashboard treats `available` as the set of rows it can render, so a chosen id
@@ -751,11 +780,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
751
780
  // deliberate 5-model roster to an unrelated visibility toggle is data loss, not a
752
781
  // filter. Same reasoning as `fetchGrokCandidateModels`, which deliberately lists a
753
782
  // model the user already excluded so its switch remains reachable.
754
- const selectableSet = new Set(selectable);
755
- const available = [
756
- ...selectable,
757
- ...[...new Set(chosen)].filter(model => !selectableSet.has(model)),
758
- ];
783
+ const available = subagentSelectableModels(config, models, listCatalogNativeSlugs());
759
784
  // #857: let CLI/GUI show when a running Codex app-server keeps an older
760
785
  // in-memory catalog than the one on disk. Bounded request-path read: the synchronous
761
786
  // collector blocked the event loop for the whole Windows CIM walk (4-7s measured).
@@ -1114,6 +1139,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1114
1139
  }
1115
1140
  }
1116
1141
  if (desktopMode === "first-party") {
1142
+ const refusal = interceptStartRefusal(ctx);
1143
+ if (refusal) return refusal;
1144
+ const started = await ensureManagementClaudeIntercept(ctx);
1145
+ if (!started.ok) return jsonResponse({ ...started, code: started.reason }, 409);
1146
+ const { claudeInterceptProxyPort } = await import("../../claude/intercept/runtime");
1147
+ const configured = claudeInterceptProxyPort(config, config.port ?? 10100);
1148
+ if (started.state.proxyPort !== configured) return jsonResponse({ ok: false, code: "port_mismatch", bound: started.state.proxyPort, configured }, 409);
1117
1149
  const { setIntegrationEnabled } = await import("../../codex/desired-state");
1118
1150
  const desired = setIntegrationEnabled("claude-desktop", true);
1119
1151
  if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
@@ -1299,6 +1331,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1299
1331
  stale: firstPartySeen.stale,
1300
1332
  interceptEnabled: firstPartySeen.interceptEnabled,
1301
1333
  interceptRunning: intercept !== null,
1334
+ ...interceptStatus(ctx),
1302
1335
  proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort,
1303
1336
  caCertPath: firstPartySeen.caCertPath,
1304
1337
  // What the running proxy routes with right now (live config), not the file on disk.
@@ -1471,6 +1504,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1471
1504
  cliFirstParty: config.claudeCode?.cliFirstParty === true,
1472
1505
  cliFirstPartyApplied: config.claudeCode?.cliFirstParty === true && sharedProxy === "live",
1473
1506
  desktopFirstParty: desired.desktop,
1507
+ ...interceptStatus(ctx),
1474
1508
  interceptEligible: eligible,
1475
1509
  interceptRunning: bound !== null && eligible,
1476
1510
  sharedProxy,
@@ -1540,14 +1574,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1540
1574
  const { captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party");
1541
1575
  const { firstPartyDesired, readFirstPartyProxyStatus, reconcileClaudeFirstPartySettings } = await import("../../claude/first-party-settings");
1542
1576
  const { commitClaudeCodeBlock } = await import("../../claude/claude-code-block");
1543
- const bound = (deps.getClaudeInterceptState ?? getClaudeInterceptState)();
1577
+ let bound = (deps.getClaudeInterceptState ?? getClaudeInterceptState)();
1544
1578
  if (body.cliFirstParty) {
1545
- if (!claudeInterceptEnabled(config)) return jsonResponse({ error: "Claude intercept is disabled", code: "intercept_disabled" }, 409);
1546
- if (bound === null) return jsonResponse({ error: "Claude intercept is unavailable", code: "intercept_unavailable" }, 409);
1579
+ const startRefusal = interceptStartRefusal(ctx);
1580
+ if (startRefusal) return startRefusal;
1581
+ const started = await ensureManagementClaudeIntercept(ctx);
1582
+ if (!started.ok) return jsonResponse({ ...started, code: started.reason }, 409);
1583
+ bound = started.state;
1547
1584
  const targetPort = claudeInterceptProxyPort(config, config.port ?? 10100);
1548
1585
  if (targetPort !== bound.proxyPort) return jsonResponse({
1549
- error: `Claude intercept port mismatch (configured ${targetPort}, bound ${bound.proxyPort}); restart needed`,
1550
- code: "intercept_unavailable",
1586
+ error: `Claude intercept port mismatch (configured ${targetPort}, bound ${bound.proxyPort})`,
1587
+ code: "port_mismatch", bound: bound.proxyPort, configured: targetPort,
1551
1588
  }, 409);
1552
1589
  let inspection: ReturnType<typeof inspectDesktopFirstParty>["settings"];
1553
1590
  try { inspection = inspectDesktopFirstParty(config).settings; }
@@ -1559,7 +1596,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1559
1596
  try { if (body.cliFirstParty) restoreSettings = captureDesktopFirstPartyRollback(config); }
1560
1597
  catch { return jsonResponse({ error: "Claude settings are unreadable", code: "unreadable" }, 500); }
1561
1598
  type FirstPartyMutation =
1562
- | { refusal: { error: string; code: "intercept_disabled" | "intercept_unavailable" } }
1599
+ | { refusal: { error: string; code: "disabled" | "port_mismatch"; bound?: number; configured?: number } }
1563
1600
  | { claudeCode: OcxConfig["claudeCode"]; previous: { present: boolean; value: boolean };
1564
1601
  pinnedMode: "first-party" | "gateway" | undefined; retainedAmbiguous: boolean };
1565
1602
  let outcome: ReturnType<typeof mutatePersistedConfig<FirstPartyMutation>>;
@@ -1567,11 +1604,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1567
1604
  outcome = mutatePersistedConfig<FirstPartyMutation>(persisted => {
1568
1605
  if (body.cliFirstParty) {
1569
1606
  if (!claudeInterceptEnabled(persisted)) return { changed: false, value: {
1570
- refusal: { error: "Claude intercept is disabled", code: "intercept_disabled" as const } } };
1607
+ refusal: { error: "Claude intercept is disabled", code: "disabled" as const } } };
1571
1608
  const persistedPort = claudeInterceptProxyPort(persisted, persisted.port ?? 10100);
1572
1609
  if (bound === null || persistedPort !== bound.proxyPort) return { changed: false, value: {
1573
- refusal: { error: `Claude intercept port mismatch (configured ${persistedPort}, bound ${bound?.proxyPort ?? "none"}); restart needed`,
1574
- code: "intercept_unavailable" as const } } };
1610
+ refusal: { error: `Claude intercept port mismatch (configured ${persistedPort}, bound ${bound?.proxyPort ?? "none"})`,
1611
+ code: "port_mismatch" as const, bound: bound?.proxyPort, configured: persistedPort } } };
1575
1612
  }
1576
1613
  const before = structuredClone(persisted);
1577
1614
  const previous = { present: Object.hasOwn(persisted.claudeCode ?? {}, "cliFirstParty"),