@bitkyc08/opencodex 2.63.0-preview.20260923 → 2.64.0-preview.20260923

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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-D5eN1TID.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-B03pW-Uf.js → Tray-Bh_ErQDh.js} +1 -1
  10. package/gui/dist/assets/index-BEq4OCOz.js +86 -0
  11. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  12. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-EJxiUFMq.js +0 -50
  259. package/gui/dist/assets/index-BmJwNBHL.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-IftE60UK.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -41,6 +41,7 @@ import {
41
41
  NamespaceToolCollisionError,
42
42
  restoreRoutedNamespaceCalls,
43
43
  } from "../../responses/namespace-tool-compat";
44
+ import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat";
44
45
  import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
45
46
  import { formatErrorResponse } from "../../bridge";
46
47
  import { redactSecretString } from "../../lib/redact";
@@ -61,7 +62,6 @@ import {
61
62
  parseMuseSubscriptionUsage,
62
63
  } from "../../providers/muse-subscription-usage";
63
64
  import { restoreMuseToolNames } from "../../responses/muse-tool-name-alias";
64
- import { restoreRoutedCustomCalls } from "../../responses/custom-tool-compat";
65
65
  import { restorePlaintextV2AgentMessageCalls } from "../../responses/plaintext-v2-agent-messages";
66
66
  import {
67
67
  recordAdapterReasoning,
@@ -122,7 +122,6 @@ import { recordCodexUpstreamOutcome } from "../../codex/routing";
122
122
  import { describeUpstreamConnectFailure } from "./upstream-error";
123
123
  import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
124
124
  import {
125
- isOpenCodeGoDestination,
126
125
  rateLimitRetryPolicyFor,
127
126
  rateLimitRetryDelayMs,
128
127
  transientRetryPolicyFor,
@@ -136,6 +135,7 @@ import { publicOAuthAuthenticationErrorMessage } from "../../oauth";
136
135
  import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
137
136
  import {
138
137
  GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
138
+ hasEligibleGenericOAuthFailoverTarget,
139
139
  isGenericOAuthFailoverEnabled,
140
140
  rotateGenericOAuthAccountOn429,
141
141
  failoverAccountSnapshot,
@@ -328,7 +328,11 @@ export async function preparePassthroughExchange(
328
328
  // unstructured 500 — and no request log — depending only on whether a rotation ran first.
329
329
  // Same shape for a tool_choice this proxy cannot honor: the destination rejects a schema the
330
330
  // catalog had to drop, so the selector naming it is a client input error, not a 500.
331
- if (error instanceof NamespaceToolCollisionError || error instanceof XaiToolSchemaCompatibilityError) {
331
+ if (
332
+ error instanceof NamespaceToolCollisionError
333
+ || error instanceof XaiToolSchemaCompatibilityError
334
+ || error instanceof RoutedCustomToolCompatError
335
+ ) {
332
336
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
333
337
  }
334
338
  throw error;
@@ -889,12 +893,6 @@ export async function preparePassthroughExchange(
889
893
  { abortSignal: upstream.signal, label: safeHostLabel(request.url),
890
894
  attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends,
891
895
  claimAmbiguousResend: claimPreHeaderResend,
892
- // The OpenCode Go destination stalls-then-drops inference sends (ambiguous
893
- // pre-header resets surfacing as refused 429s); its subscription traffic is
894
- // inference-only, so a bounded reset replay here absorbs the blip instead of
895
- // failing the turn. Recovery legs keep the fail-closed refusal; only this
896
- // initial send is replay-eligible. Attempts stay budget-bounded via attempts.
897
- replaySafe: isOpenCodeGoDestination(route.provider),
898
896
  },
899
897
  );
900
898
  } catch (err) {
@@ -1310,10 +1308,11 @@ export async function preparePassthroughExchange(
1310
1308
  // No credential moved, so the reservation costs nothing.
1311
1309
  hop.permit?.release();
1312
1310
  } else {
1313
- // Rotation was available -- the roster cap above admitted it -- and the shared request
1314
- // budget refused. Recorded so a one-send log is not read as "nothing was eligible",
1315
- // which is the ambiguity this attribution exists to remove (#5044).
1316
- noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
1311
+ // The activation quorum ignores cooldowns; prove that the selector has a live alternate
1312
+ // before describing this as a recovery that only the shared request budget withheld.
1313
+ if (hasEligibleGenericOAuthFailoverTarget(
1314
+ route.providerName, transportState.genericFailoverAccountId, Date.now(), route.modelId,
1315
+ )) noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
1317
1316
  }
1318
1317
  }
1319
1318
 
@@ -8,6 +8,8 @@ import { handleResponses as handleResponsesCore } from "./core";
8
8
  import { requestPacingOverloadResponse } from "./pacing-overload";
9
9
  import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar";
10
10
  import { captureCallerDirectAuth } from "../../providers/caller-authorization";
11
+ import { resolvePolicyProfileId } from "../../routing/profile";
12
+ import { parseSyntheticRowId } from "../fast-row";
11
13
 
12
14
  type CoreHandler = typeof handleResponsesCore;
13
15
  type CoreOptions = Parameters<CoreHandler>[3];
@@ -146,7 +148,16 @@ export async function handleResponsesWithPolicyFallback(
146
148
  } : {}),
147
149
  onRequestBodyParsed: body => {
148
150
  options.onRequestBodyParsed?.(body);
149
- if (body && typeof body === "object" && !Array.isArray(body)) rawBody = body as Record<string, unknown>;
151
+ if (rawBody === null && body && typeof body === "object" && !Array.isArray(body)
152
+ && typeof (body as { model?: unknown }).model === "string") {
153
+ const model = (body as { model: string }).model;
154
+ const { fastRow, effortRow } = parseSyntheticRowId(model, config);
155
+ if (resolvePolicyProfileId(config, fastRow?.baseId ?? effortRow?.baseId ?? model) === null) return;
156
+ // Recovery and other core preparation may mutate the parsed body in place. Keep an
157
+ // immutable snapshot of the original wire body so a retry cannot serialize those
158
+ // mutations. Object-identity metadata is re-established by each attempt, not serialized.
159
+ rawBody = structuredClone(body as Record<string, unknown>);
160
+ }
150
161
  },
151
162
  onStoredPool401ReplayDispatched: () => {
152
163
  storedPool401ReplayDispatched = true;
@@ -13,6 +13,7 @@ import {
13
13
  } from "./core-errors";
14
14
  import { parseSyntheticRowId } from "../fast-row";
15
15
  import { resolveComboId, comboIdFromRawBody, NoAvailableComboTargetsError } from "../../combos";
16
+ import { INTERCEPT_TARGET_UNAVAILABLE_CODE, interceptTargetUnavailableResponse, resolveShadowCallTarget } from "./shadow-target-availability";
16
17
  import { recallComboForLane } from "./combo-session-recall";
17
18
  import {
18
19
  sessionLaneIdFromRequest,
@@ -38,6 +39,7 @@ import {
38
39
  applyCodexAuthContextToProvider,
39
40
  hasCallerCodexBearer,
40
41
  requestOwnedMainPinState,
42
+ requestOwnedMainCredentialIsLive,
41
43
  } from "../../codex/auth-context";
42
44
  import {
43
45
  copyPreviousResponseReplayProvenance,
@@ -226,6 +228,7 @@ export async function prepareResponsesRequest(
226
228
  // hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector
227
229
  // here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG
228
230
  // LOOKUP so the check can never observe a one-candidate collapse.
231
+ let shadowCallIntercepted = false;
229
232
  if (!options.comboAttempt && !options.compactionRoutingOverride && body && typeof body === "object" && !Array.isArray(body)) {
230
233
  const shadowIntercept = config.shadowCallIntercept;
231
234
  const rawShadowModel = (body as { model?: unknown }).model;
@@ -233,6 +236,7 @@ export async function prepareResponsesRequest(
233
236
  && isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) {
234
237
  const shadowComboId = resolveComboId(config, shadowIntercept.model);
235
238
  if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) {
239
+ shadowCallIntercepted = true;
236
240
  (body as Record<string, unknown>).model = shadowIntercept.model;
237
241
  // Same rule as the late intercept site: record the operator-configured prefix that
238
242
  // matched, never the caller's raw model string. Matching is by prefix, so the raw
@@ -248,6 +252,9 @@ export async function prepareResponsesRequest(
248
252
  options.onRequestBodyRead?.();
249
253
  return requestDispatchers.handleComboResponses(req, body, comboId, config, logCtx, {
250
254
  ...options,
255
+ // Concrete combo child selectors no longer match the shadow source model. Carry the
256
+ // interception decision explicitly so provider-specific helper isolation still applies.
257
+ shadowCallIntercepted,
251
258
  // The original request body was accepted above. Combo children are synthetic
252
259
  // replays and must not repeat the caller-owned timeout transition.
253
260
  onRequestBodyRead: undefined,
@@ -370,6 +377,7 @@ export async function prepareResponsesRequest(
370
377
  }
371
378
  }
372
379
  if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId;
380
+ if (options.shadowCallIntercepted === true) parsed._cursorIsolateConversation = true;
373
381
  } catch (err) {
374
382
  if (isTranslatorBudgetExceededError(err)) {
375
383
  return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
@@ -482,7 +490,15 @@ export async function prepareResponsesRequest(
482
490
  const resolvedSource = routeConcreteModel(config, parsed.modelId);
483
491
  sourceIdentity = { providerName: resolvedSource.providerName, modelId: sourcePrefix };
484
492
  } catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ }
485
- const targetRoute = resolveRoute(_sci.model);
493
+ // A dead target fails this helper call once, before any send; it never falls through to
494
+ // the native source model or to the default provider (#5618).
495
+ const target = resolveShadowCallTarget(_sci.model, resolveRoute);
496
+ if ("unavailable" in target) {
497
+ logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(sourcePrefix);
498
+ logCtx.errorCode = INTERCEPT_TARGET_UNAVAILABLE_CODE;
499
+ return interceptTargetUnavailableResponse(_sci.model, target.unavailable);
500
+ }
501
+ const targetRoute = target.route;
486
502
  if (shouldInterceptShadowCall(parsed.modelId, _sci.sourceModels, sourceIdentity, targetRoute)) {
487
503
  credentialDomainWasRewritten = true;
488
504
  const _sciOriginal = parsed.modelId;
@@ -580,7 +596,19 @@ export async function prepareResponsesRequest(
580
596
  options.codexAuthPolicy ?? config,
581
597
  previewRequestScopedMainCredential,
582
598
  route.codexAccountId,
599
+ codexQuotaScopeForModel(route.modelId),
583
600
  ).preserve;
601
+ // Final auth's own liveness answer for a request-owned bearer, from its own shared expression
602
+ // (#5019). Predicting the pin alone dropped main here and handed subagent fallback a different
603
+ // account than the one that serves.
604
+ const previewRequestOwnedMainCredentialLive = requestOwnedMainCredentialIsLive({
605
+ preserveRequestOwnedMainPin: previewRequestOwnedMainPin,
606
+ requestScopedMainCredential: previewRequestScopedMainCredential,
607
+ nativeMainTrafficBlocked: nativeMainRecoveryBlocked,
608
+ mainProfileDraining: previewSelectionAdmission?.mainProfileDraining === true,
609
+ // Final-auth-only, and stated rather than defaulted: see the field's own note.
610
+ callerOwnsCooledPoolSubscription: false,
611
+ });
584
612
  // Deliberately NOT fenced on ownership: final auth derives `nativeMainSelectionOnly` from the
585
613
  // drain alone, and adding a term here would diverge from it in the other direction.
586
614
  const previewSelectionOptions = {
@@ -592,12 +620,13 @@ export async function prepareResponsesRequest(
592
620
  // spawn, because subagent fallback re-enters the preview through the callback below.
593
621
  //
594
622
  // Scoped to ownership, and carrying final auth's value rather than a constant, because
595
- // preview exists to predict final auth. Under an effective main pin the request really is
596
- // served by its own main credential, so main must stay eligible; without the pin final auth
597
- // scores main `main_credential_unavailable` and drops it, so preview has to drop it too. A
598
- // hardcoded `true` would be wrong in the second case and `false` in the first.
623
+ // preview exists to predict final auth. The request really is served by its own main
624
+ // credential whenever that bearer is forwardable, so main stays eligible; while retained
625
+ // recovery or a profile drain fences the physical identity, final auth drops main and preview
626
+ // has to drop it too. A hardcoded `true` would be wrong in the second case and `false` in
627
+ // the first.
599
628
  isMainAccountTokenLive: previewRequestScopedMainCredential
600
- ? () => previewRequestOwnedMainPin
629
+ ? () => previewRequestOwnedMainCredentialLive
601
630
  : undefined,
602
631
  // Preview must reach the same answer as the final resolution, including the uploaded-file
603
632
  // retention (#4778): a preview that reported a quota move the request will not make would
@@ -836,12 +865,22 @@ export async function prepareResponsesRequest(
836
865
  options.codexAuthPolicy ?? config,
837
866
  recoveryRequestScopedMainCredential,
838
867
  route.codexAccountId,
868
+ codexQuotaScopeForModel(route.modelId),
839
869
  ).preserve;
870
+ // Same shared expression as the first preview and as final auth (#5019), recomputed
871
+ // against the route recovery may have moved to.
872
+ const recoveryRequestOwnedMainCredentialLive = requestOwnedMainCredentialIsLive({
873
+ preserveRequestOwnedMainPin: recoveryRequestOwnedMainPin,
874
+ requestScopedMainCredential: recoveryRequestScopedMainCredential,
875
+ nativeMainTrafficBlocked: recoveryNativeMainBlocked,
876
+ mainProfileDraining: recoverySelectionAdmission?.mainProfileDraining === true,
877
+ callerOwnsCooledPoolSubscription: false,
878
+ });
840
879
  const recoverySelectionOptions = {
841
880
  nativeMainSelectionOnly: !recoveryNativeMainBlocked
842
881
  && recoverySelectionAdmission?.mainProfileDraining === true,
843
882
  isMainAccountTokenLive: recoveryRequestScopedMainCredential
844
- ? () => recoveryRequestOwnedMainPin
883
+ ? () => recoveryRequestOwnedMainCredentialLive
845
884
  : undefined,
846
885
  // #4778, same reason as `previewSelectionOptions` above: this preview decides
847
886
  // which account subagent fallback scores against, and final auth passes the
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { RequestSendObserver } from "../../lib/request-execution-budget";
3
3
  import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
4
+ import { SpendLedgerOwnerError } from "../../lib/spend-ledger-owner";
4
5
  import { markLocalRequestLogRefusal, type RequestLogContext } from "../request-log";
5
6
  import { recordWorkflowRefusalEvent, workflowDenialSummary } from "../../lib/workflow-budget";
6
7
 
@@ -42,7 +43,7 @@ export interface RequestSpendTracker extends RequestSendObserver, RequestSpendSe
42
43
  export function createRequestSpendTracker(
43
44
  logCtx: Pick<
44
45
  RequestLogContext,
45
- "provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens"
46
+ "provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens" | "spendInputEstimateTokens"
46
47
  > & Partial<Pick<RequestLogContext, "localTerminalReason" | "terminalSource" | "errorCode">>,
47
48
  rootId: string | undefined,
48
49
  injected?: SpendReservationLedger,
@@ -57,6 +58,7 @@ export function createRequestSpendTracker(
57
58
  const live: string[] = [];
58
59
  let refusals = 0;
59
60
  let resolved = false;
61
+ let terminalProcessed = false;
60
62
  /**
61
63
  * Confirm the sends this request has already moved past.
62
64
  *
@@ -88,7 +90,7 @@ export function createRequestSpendTracker(
88
90
  ...(logCtx.accountLogLabel !== undefined ? { identityId: logCtx.accountLogLabel } : {}),
89
91
  ...(logCtx.provider !== undefined ? { poolId: logCtx.provider } : {}),
90
92
  },
91
- inputTokens: logCtx.usageLogInputTokens ?? 0,
93
+ inputTokens: logCtx.spendInputEstimateTokens ?? logCtx.usageLogInputTokens ?? 0,
92
94
  outputCeilingTokens: logCtx.spendOutputCeilingTokens ?? 0,
93
95
  ...(alreadySent ? { alreadySent: true } : {}),
94
96
  });
@@ -135,21 +137,35 @@ export function createRequestSpendTracker(
135
137
  },
136
138
  settle(usage: TerminalSpendUsage | undefined): void {
137
139
  if (resolved) return;
138
- resolved = true;
139
- const terminal = live.pop();
140
- if (terminal !== undefined) {
141
- const reported = typeof usage?.inputTokens === "number" || typeof usage?.outputTokens === "number";
142
- if (reported) {
143
- ledger().settle(terminal, {
144
- inputTokens: usage?.inputTokens ?? 0,
145
- outputTokens: usage?.outputTokens ?? 0,
146
- });
147
- } else {
148
- // The response never reported usage. It may still have been billed.
149
- ledger().markLost(terminal);
140
+ try {
141
+ if (!terminalProcessed && live.length > 0) {
142
+ const terminal = live[live.length - 1] as string;
143
+ const reported = typeof usage?.inputTokens === "number" || typeof usage?.outputTokens === "number";
144
+ if (reported) {
145
+ ledger().settle(terminal, {
146
+ inputTokens: usage?.inputTokens ?? 0,
147
+ outputTokens: usage?.outputTokens ?? 0,
148
+ });
149
+ } else {
150
+ // The response never reported usage. It may still have been billed.
151
+ ledger().markLost(terminal);
152
+ }
153
+ live.pop();
154
+ terminalProcessed = true;
150
155
  }
156
+ while (live.length > 0) {
157
+ ledger().markLost(live[live.length - 1] as string);
158
+ live.pop();
159
+ }
160
+ resolved = true;
161
+ } catch (error) {
162
+ // A deferred final log may arrive after server.stop released this ledger's lease.
163
+ // Only that ended ownership can discard sends already reserved by this tracker.
164
+ if (live.length === 0 || !(error instanceof SpendLedgerOwnerError)
165
+ || error.code !== "SPEND_LEDGER_OWNER_NOT_HELD") throw error;
166
+ live.length = 0;
167
+ resolved = true;
151
168
  }
152
- for (const sendId of live.splice(0)) ledger().markLost(sendId);
153
169
  },
154
170
  get refusals(): number { return refusals; },
155
171
  };
@@ -23,6 +23,7 @@ import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE } from "../../lib
23
23
  import { SendBudgetExhaustedError } from "../../lib/upstream-retry";
24
24
  import {
25
25
  GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
26
+ hasEligibleGenericOAuthFailoverTarget,
26
27
  isGenericOAuthFailoverEnabled,
27
28
  rotateGenericOAuthAccountOn429,
28
29
  failoverAccountSnapshot,
@@ -253,11 +254,11 @@ export async function executeResponsesRunTurn(
253
254
  `${route.providerName}|${route.modelId}|runturn-oauth-429`,
254
255
  );
255
256
  if (!hop.allowed) {
256
- // The roster bound above already said this credential set may rotate again; the shared
257
- // request budget is what refused. Returning false lets the preflight 429 reach the
258
- // client unchanged, which is right, but it used to leave a log indistinguishable from
259
- // a request where no rotation was ever available (#5044).
260
- noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
257
+ // The activation quorum deliberately ignores cooldowns. Attribute a withheld recovery
258
+ // only when the non-mutating selector proves a usable alternate exists right now.
259
+ if (hasEligibleGenericOAuthFailoverTarget(
260
+ route.providerName, transportState.genericFailoverAccountId, Date.now(), route.modelId,
261
+ )) noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
261
262
  return false;
262
263
  }
263
264
  const nextAccountId = rotateGenericOAuthAccountOn429(
@@ -0,0 +1,61 @@
1
+ import { NoAvailableComboTargetsError } from "../../combos";
2
+ import { NoEligiblePolicyCandidateError, type RouteResult } from "../../router";
3
+ import { AdmissionModelDeniedError } from "../admission-model-scope";
4
+
5
+ /**
6
+ * Shadow-call interception names one operator-chosen target (#5618). When that target stops
7
+ * resolving (its provider disabled or deleted, its combo removed), the helper call must fail once,
8
+ * clearly, before anything is sent. It must not pass through to the native model it was meant to
9
+ * replace, and it must not reach the default provider through the router's last-resort fallback:
10
+ * either would change the destination, the credentials and the cost without the operator choosing
11
+ * it. A combo or routing-profile target keeps its own declared failover, which is the fallback the
12
+ * operator approved.
13
+ */
14
+ export const INTERCEPT_TARGET_UNAVAILABLE_CODE = "intercept_target_unavailable";
15
+
16
+ /** Non-retryable: the target stays unavailable until the operator changes the configuration. */
17
+ export const INTERCEPT_TARGET_UNAVAILABLE_STATUS = 409;
18
+
19
+ export type ShadowTargetResolution = { route: RouteResult } | { unavailable: string };
20
+
21
+ /**
22
+ * Resolve the configured target. Admission-scope refusals, exhausted combos and policy
23
+ * evaluations keep their existing responses, so they are rethrown to the caller's handler.
24
+ */
25
+ export function resolveShadowCallTarget(
26
+ model: string,
27
+ resolve: (model: string) => RouteResult,
28
+ ): ShadowTargetResolution {
29
+ let route: RouteResult;
30
+ try {
31
+ route = resolve(model);
32
+ } catch (error) {
33
+ if (error instanceof AdmissionModelDeniedError
34
+ || error instanceof NoAvailableComboTargetsError
35
+ || error instanceof NoEligiblePolicyCandidateError) throw error;
36
+ return { unavailable: error instanceof Error ? error.message : String(error) };
37
+ }
38
+ // A qualified target whose provider segment matched nothing reaches the default provider only
39
+ // through the router's terminal fallback. A bare target resolved that way is the operator's
40
+ // documented choice and stays valid.
41
+ if (route.routeKind === "default-provider" && model.includes("/")) {
42
+ return { unavailable: "it names no configured provider and would only reach the default provider" };
43
+ }
44
+ return { route };
45
+ }
46
+
47
+ const warnedTargets = new Set<string>();
48
+
49
+ export function interceptTargetUnavailableResponse(model: string, detail: string): Response {
50
+ const message = `Shadow call intercept target "${model}" is unavailable: ${detail}. `
51
+ + "Choose another target in the shadow call settings or re-enable its provider.";
52
+ const key = `${model}\u0000${detail}`;
53
+ if (!warnedTargets.has(key)) {
54
+ warnedTargets.add(key);
55
+ console.warn(`shadow-call: ${message}`);
56
+ }
57
+ return new Response(
58
+ JSON.stringify({ error: { message, type: "invalid_request_error", code: INTERCEPT_TARGET_UNAVAILABLE_CODE } }),
59
+ { status: INTERCEPT_TARGET_UNAVAILABLE_STATUS, headers: { "Content-Type": "application/json" } },
60
+ );
61
+ }
@@ -1,6 +1,7 @@
1
1
  import type { TranslatorBudget } from "../lib/translator-budget";
2
2
  import { mayBecomePatchEnvelope, normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope";
3
3
  import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
4
+ import { mayBecomeCodeModeShellInput } from "../responses/code-mode-shell-input";
4
5
  import { progressiveFreeformInput } from "../responses/progressive-freeform-input";
5
6
  import { declaresCodeModeExec } from "../types/tools";
6
7
  import {
@@ -320,6 +321,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
320
321
  && itemName?.name === "exec";
321
322
  const mayNormalize = ownsFreeformGrammar && itemName?.name === "apply_patch";
322
323
  if ((mayCompile || mayNormalize) && mayBecomePatchEnvelope(fullInput)) return [];
324
+ if (mayCompile && mayBecomeCodeModeShellInput(open.argumentsText, fullInput)) return [];
323
325
  if (!fullInput.startsWith(open.emittedInput) || fullInput.length === open.emittedInput.length) return [];
324
326
  const inputDelta = fullInput.slice(open.emittedInput.length);
325
327
  open.emittedInput = fullInput;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `ocx service claim` — record an ownership claim the embedding shell already got consent for.
3
+ *
4
+ * The desktop cannot write the record itself: the claim has to be committed under the
5
+ * ownership mutation lease, against the subject and managing-CLI compatibility the caller
6
+ * observed, by the module that owns all three. `recordServiceOwner` is that module; this
7
+ * verb is the wire that hands it the approval `ocx resolve --json` produced.
8
+ *
9
+ * Every expectation is mandatory because the claim is only valid against the exact answer
10
+ * the consent prompt was approved from. A subject or compatibility that moved in between
11
+ * is a fresh situation, and fresh approval is the only thing that covers it.
12
+ */
13
+ import {
14
+ recordServiceOwner,
15
+ resolveServiceState,
16
+ type RecordServiceOwnerDeps,
17
+ type ServiceOwner,
18
+ type ServiceOwnershipSubject,
19
+ } from "./state";
20
+ import {
21
+ SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
22
+ SERVICE_OWNERSHIP_PROTOCOL_VERSION,
23
+ } from "./install-state-contract.mjs";
24
+ import { observeManagingClis } from "./managing-cli";
25
+ import type { ManagingCliObservation, ManagingCliRole } from "./ownership-compatibility";
26
+
27
+ export const CLAIM_SCHEMA = "ocx-service-claim/1";
28
+
29
+ const CLAIM_USAGE = [
30
+ "Usage: ocx service claim --owner <cli|desktop> --install-id <id>",
31
+ " (--expect-none | --expect-owner <cli|desktop> --expect-install-id <id> --expect-generation <n>)",
32
+ " --expect-revision <n> --expect-compatibility-token <hex> [--json]",
33
+ ].join("\n");
34
+
35
+ export interface ClaimArgs {
36
+ owner: ServiceOwner;
37
+ installId: string;
38
+ expectedSubject: ServiceOwnershipSubject;
39
+ compatibilityToken: string;
40
+ json: boolean;
41
+ }
42
+
43
+ export type ClaimParseResult = { ok: true; args: ClaimArgs } | { ok: false };
44
+
45
+ function readFlag(args: string[], index: number): string {
46
+ return args[index + 1] ?? "";
47
+ }
48
+
49
+ function readOwner(value: string): ServiceOwner | null {
50
+ return value === "cli" || value === "desktop" ? value : null;
51
+ }
52
+
53
+ function readRevision(value: string): number | null {
54
+ if (!/^\d+$/.test(value)) return null;
55
+ const parsed = Number(value);
56
+ return Number.isSafeInteger(parsed) ? parsed : null;
57
+ }
58
+
59
+ /** Strict parse: the whole approval arrives on argv or the invocation is a usage error. */
60
+ export function parseClaimArgs(args: string[]): ClaimParseResult {
61
+ let owner: ServiceOwner | null = null;
62
+ let installId: string | null = null;
63
+ let expectNone = false;
64
+ let expectOwner: ServiceOwner | null = null;
65
+ let expectInstallId: string | null = null;
66
+ let expectGeneration: number | null = null;
67
+ let expectRevision: number | null = null;
68
+ let token: string | null = null;
69
+ let json = false;
70
+ const withValue = new Set([
71
+ "--owner", "--install-id", "--expect-owner", "--expect-install-id",
72
+ "--expect-generation", "--expect-revision", "--expect-compatibility-token",
73
+ ]);
74
+ for (let index = 0; index < args.length; index += 1) {
75
+ const arg = args[index]!;
76
+ if (arg === "--json") { json = true; continue; }
77
+ if (arg === "--expect-none") { expectNone = true; continue; }
78
+ if (!withValue.has(arg)) return { ok: false };
79
+ const value = readFlag(args, index);
80
+ if (!value) return { ok: false };
81
+ index += 1;
82
+ switch (arg) {
83
+ case "--owner": owner = readOwner(value); if (owner === null) return { ok: false }; break;
84
+ case "--install-id": installId = value; break;
85
+ case "--expect-owner": expectOwner = readOwner(value); if (expectOwner === null) return { ok: false }; break;
86
+ case "--expect-install-id": expectInstallId = value; break;
87
+ case "--expect-generation": expectGeneration = readRevision(value); if (expectGeneration === null) return { ok: false }; break;
88
+ case "--expect-revision": expectRevision = readRevision(value); if (expectRevision === null) return { ok: false }; break;
89
+ case "--expect-compatibility-token": token = value; break;
90
+ }
91
+ }
92
+ if (!owner || !installId || expectRevision === null || !token) return { ok: false };
93
+ let expectedSubject: ServiceOwnershipSubject;
94
+ if (expectNone) {
95
+ if (expectOwner !== null || expectInstallId !== null || expectGeneration !== null) return { ok: false };
96
+ expectedSubject = { kind: "none", revision: expectRevision };
97
+ } else {
98
+ if (expectOwner === null || expectInstallId === null || expectGeneration === null) return { ok: false };
99
+ expectedSubject = {
100
+ kind: "owned",
101
+ ownership: { owner: expectOwner, installId: expectInstallId, consentGeneration: expectGeneration },
102
+ revision: expectRevision,
103
+ };
104
+ }
105
+ return { ok: true, args: { owner, installId, expectedSubject, compatibilityToken: token, json } };
106
+ }
107
+
108
+ export interface ServiceClaimDeps {
109
+ /** recordServiceOwner seam. */
110
+ recordOwner?: typeof recordServiceOwner;
111
+ /** The managing-CLI revalidation the record runs inside its lock. */
112
+ observeManagers?: () => Readonly<Record<ManagingCliRole, ManagingCliObservation>>;
113
+ /** Re-resolves the state under the lock for the observation. */
114
+ resolveState?: typeof resolveServiceState;
115
+ stdout?: { log: (s: string) => void };
116
+ stderr?: { error: (s: string) => void };
117
+ }
118
+
119
+ interface ClaimError extends Error {
120
+ code?: string;
121
+ }
122
+
123
+ /**
124
+ * Run `ocx service claim`. Returns the exit code; the caller assigns it, so the verb
125
+ * reports before the process decides.
126
+ */
127
+ export async function runServiceClaim(argv: string[], deps: ServiceClaimDeps = {}): Promise<number> {
128
+ const stdout = deps.stdout ?? console;
129
+ const stderr = deps.stderr ?? console;
130
+ const parsed = parseClaimArgs(argv);
131
+ if (!parsed.ok) {
132
+ stderr.error(CLAIM_USAGE);
133
+ return 64;
134
+ }
135
+ const { args } = parsed;
136
+ const resolveState = deps.resolveState ?? resolveServiceState;
137
+ const observeManagers = deps.observeManagers ?? (() => {
138
+ const resolved = resolveState();
139
+ if (resolved.kind === "unknown") {
140
+ // recordServiceOwner converts a thrown observation into a compatibility-changed
141
+ // refusal; an unreadable state under the lock is exactly that.
142
+ throw new Error(resolved.reason);
143
+ }
144
+ return observeManagingClis(resolved.kind === "state" ? resolved.state : null);
145
+ });
146
+ const recordOwner = deps.recordOwner ?? recordServiceOwner;
147
+ const request = {
148
+ owner: args.owner,
149
+ installId: args.installId,
150
+ expectedSubject: args.expectedSubject,
151
+ expectedCompatibility: {
152
+ kind: "supported" as const,
153
+ protocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION,
154
+ minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
155
+ token: args.compatibilityToken,
156
+ },
157
+ };
158
+ try {
159
+ const recordDeps: RecordServiceOwnerDeps = { observeManagers };
160
+ const committed = recordOwner(request, recordDeps);
161
+ if (args.json) {
162
+ stdout.log(JSON.stringify({
163
+ schema: CLAIM_SCHEMA,
164
+ ok: true,
165
+ ownership: committed.ownership,
166
+ revision: committed.revision,
167
+ }));
168
+ } else {
169
+ stdout.log(
170
+ `✅ Recorded ${args.owner} as the runtime owner (install ${args.installId}, generation ${committed.ownership.consentGeneration}).`,
171
+ );
172
+ }
173
+ return 0;
174
+ } catch (error) {
175
+ const claimError = error as ClaimError;
176
+ const code = typeof claimError?.code === "string" ? claimError.code : "claim-failed";
177
+ const message = claimError instanceof Error ? claimError.message : String(error);
178
+ if (args.json) {
179
+ stdout.log(JSON.stringify({ schema: CLAIM_SCHEMA, ok: false, code, message }));
180
+ } else {
181
+ stderr.error(`❌ ${message}`);
182
+ }
183
+ return 1;
184
+ }
185
+ }
@@ -21,6 +21,7 @@ import { inspectWindowsSchedulerServiceStatus, schtasksErrorDetail, probeWindows
21
21
  import type { WindowsSchedulerTaskProbe } from "./windows-scheduler";
22
22
  import { win32 } from "node:path";
23
23
  import { serviceDiagnosticsSummary } from "./diagnostics";
24
+ import { runServiceClaim } from "./claim";
24
25
 
25
26
  /**
26
27
  * `restart` is NO LONGER folded into `repair`.
@@ -184,6 +185,14 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
184
185
  export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
185
186
  const filteredArgs = args.filter((a): a is string => Boolean(a));
186
187
  const execute = async (): Promise<void> => {
188
+ // `claim` is not an install verb: it is deliberately outside planServiceCommand (whose
189
+ // backend/installation checks do not apply to an ownership write) and outside
190
+ // assertServiceEnvironmentMatchesInstall — a takeover is not an install.
191
+ if (filteredArgs[0] === "claim") {
192
+ const code = await runServiceClaim(filteredArgs.slice(1));
193
+ if (code !== 0) process.exitCode = code;
194
+ return;
195
+ }
187
196
  // Planning reads manager state. Repeat it only after the writer lock is held, otherwise a
188
197
  // bare command can choose install from a snapshot another service command already changed.
189
198
  const plan = planServiceCommand(filteredArgs);
@@ -419,7 +428,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
419
428
  console.log("✅ service uninstalled.");
420
429
  break;
421
430
  default:
422
- console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
431
+ console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove|claim] [--native|--scheduler]");
423
432
  console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
424
433
  console.error(" repair: refresh the installed backend, reloading it only when the definition changed; stale Windows tasks may request admin approval.");
425
434
  console.error(" restart: the same refresh, but always restarts the service — on macOS a healthy job is kickstarted in place.");