@bitkyc08/opencodex 2.59.0 → 2.60.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 (194) hide show
  1. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  2. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -1
  5. package/src/adapters/anthropic-image-codec.ts +16 -2
  6. package/src/adapters/anthropic-image-normalize.ts +49 -2
  7. package/src/adapters/anthropic.ts +4 -1
  8. package/src/adapters/base.ts +23 -0
  9. package/src/adapters/coding-agent/turn.ts +22 -2
  10. package/src/adapters/command-code.ts +50 -3
  11. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  12. package/src/adapters/cursor/discovery.ts +11 -8
  13. package/src/adapters/cursor/live-transport.ts +26 -9
  14. package/src/adapters/cursor/request-builder.ts +7 -1
  15. package/src/adapters/cursor/transport.ts +19 -0
  16. package/src/adapters/cursor.ts +3 -1
  17. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  18. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  19. package/src/adapters/devin.ts +50 -13
  20. package/src/adapters/google-antigravity-replay.ts +1 -1
  21. package/src/adapters/google-antigravity-wire.ts +29 -5
  22. package/src/adapters/google-http.ts +49 -10
  23. package/src/adapters/google-tool-schema.ts +595 -31
  24. package/src/adapters/google-wire-compiler.ts +93 -10
  25. package/src/adapters/google-wire-shape.ts +461 -0
  26. package/src/adapters/google.ts +37 -6
  27. package/src/adapters/openai-chat-images.ts +3 -1
  28. package/src/adapters/openai-chat.ts +5 -1
  29. package/src/adapters/openai-responses/image-gen.ts +8 -6
  30. package/src/adapters/openai-responses/passthrough.ts +15 -3
  31. package/src/adapters/openai-responses/reasoning.ts +7 -0
  32. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  33. package/src/bridge/sse.ts +3 -151
  34. package/src/cli/account-extended.ts +4 -4
  35. package/src/cli/dispatch.ts +3 -3
  36. package/src/cli/doctor.ts +28 -9
  37. package/src/cli/hub.ts +3 -2
  38. package/src/cli/index.ts +7 -2
  39. package/src/cli/opencode.ts +2 -2
  40. package/src/cli/provider.ts +13 -1
  41. package/src/client/machine-api.ts +2 -2
  42. package/src/client/machine-listener.ts +2 -2
  43. package/src/client/runtime.ts +26 -2
  44. package/src/codex/account-store.ts +65 -0
  45. package/src/codex/auth-api/account-list.ts +19 -11
  46. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  47. package/src/codex/catalog/gather-capture.ts +21 -2
  48. package/src/codex/catalog/model-hints.ts +29 -28
  49. package/src/codex/catalog/parsing.ts +7 -0
  50. package/src/codex/catalog/provider-models.ts +19 -2
  51. package/src/codex/catalog/retained-sync.ts +22 -26
  52. package/src/codex/catalog/routed-gather.ts +19 -0
  53. package/src/codex/context-compat.ts +5 -2
  54. package/src/codex/desired-state.ts +4 -1
  55. package/src/codex/history-job.ts +6 -6
  56. package/src/codex/history-provider.ts +20 -166
  57. package/src/codex/history-rollout-read.ts +174 -0
  58. package/src/codex/internal/catalog-writer.ts +33 -1
  59. package/src/codex/model-cache.ts +47 -0
  60. package/src/codex/model-entitlements.ts +29 -10
  61. package/src/codex/observed-model-denials.ts +101 -8
  62. package/src/codex/prompt-text-probe.ts +9 -6
  63. package/src/codex/routing.ts +7 -1
  64. package/src/codex/shim.ts +1 -1
  65. package/src/codex/subagent-model-fallback.ts +22 -4
  66. package/src/combos/failover.ts +3 -0
  67. package/src/config/admitted-identity.ts +222 -0
  68. package/src/config/diagnostics.ts +22 -1
  69. package/src/config/feature-flags.ts +5 -0
  70. package/src/config/load-degrade.ts +18 -0
  71. package/src/config/proxy-env.ts +8 -2
  72. package/src/config/schema/compaction-triggers.ts +11 -0
  73. package/src/config/schema/config-schema.ts +4 -0
  74. package/src/config/schema/leaf-validators.ts +12 -0
  75. package/src/config.ts +2 -2
  76. package/src/generated/compatibility-version.json +249 -169
  77. package/src/grok/reset-coupons.ts +38 -19
  78. package/src/images/loop.ts +6 -1
  79. package/src/integrations/aside-profile-context.ts +37 -3
  80. package/src/integrations/aside-profile-journal.ts +68 -3
  81. package/src/integrations/aside-profiles.ts +128 -3
  82. package/src/integrations/mutation-plan.ts +815 -0
  83. package/src/integrations/writer.ts +85 -99
  84. package/src/lab/live/transport.ts +4 -0
  85. package/src/lab/live/types.ts +5 -0
  86. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  87. package/src/lib/admin-secrets.ts +9 -1
  88. package/src/lib/debug-log-buffer.ts +6 -1
  89. package/src/lib/debug.ts +23 -0
  90. package/src/lib/errors.ts +79 -0
  91. package/src/lib/http-response-semantics.ts +57 -0
  92. package/src/lib/lab-live-pinned-sender.ts +26 -12
  93. package/src/lib/pinned-http.ts +142 -2
  94. package/src/lib/plain-data.ts +103 -0
  95. package/src/lib/process-control.ts +13 -5
  96. package/src/lib/provider-outbound.ts +50 -2
  97. package/src/lib/socks5-fetch.ts +136 -26
  98. package/src/lib/spend-ledger-owner.ts +364 -0
  99. package/src/lib/spend-reservation-ledger.ts +218 -27
  100. package/src/lib/windows-system-proxy.ts +16 -11
  101. package/src/oauth/callback-server.ts +4 -3
  102. package/src/oauth/generic-account-failover.ts +1 -0
  103. package/src/oauth/health.ts +12 -1
  104. package/src/oauth/index.ts +3 -107
  105. package/src/oauth/login-flow-state.ts +127 -0
  106. package/src/providers/derive.ts +34 -17
  107. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  108. package/src/providers/key-failover.ts +66 -18
  109. package/src/providers/model-rename-migration.ts +55 -1
  110. package/src/providers/model-rename-startup.ts +7 -5
  111. package/src/providers/openai-virtual-models.ts +42 -2
  112. package/src/providers/quota/antigravity.ts +22 -2
  113. package/src/providers/quota/vendor-probes-key.ts +1 -1
  114. package/src/providers/registry/entries-core.ts +39 -17
  115. package/src/providers/registry/entries-extended.ts +35 -1
  116. package/src/providers/registry/model-ids.ts +168 -0
  117. package/src/providers/registry/model-seeds.ts +9 -0
  118. package/src/providers/registry/types.ts +2 -0
  119. package/src/providers/resolved-model-policy-merge.ts +167 -0
  120. package/src/providers/resolved-model-policy.ts +406 -0
  121. package/src/providers/stale-vision-classification-migration.ts +137 -0
  122. package/src/responses/apply-patch-envelope.ts +0 -12
  123. package/src/responses/freeform-wrapper-scan.ts +279 -0
  124. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  125. package/src/responses/progressive-freeform-input.ts +130 -0
  126. package/src/responses/reasoning-envelope.ts +30 -0
  127. package/src/responses/state.ts +5 -12
  128. package/src/responses/tool-name-aliases.ts +15 -1
  129. package/src/router.ts +91 -115
  130. package/src/routing/compatibility/behavior.ts +9 -0
  131. package/src/routing/compatibility/subject.ts +16 -1
  132. package/src/server/adapter-resolve.ts +9 -0
  133. package/src/server/auth-cors.ts +3 -0
  134. package/src/server/chat-completions.ts +5 -2
  135. package/src/server/claude-messages.ts +6 -3
  136. package/src/server/effort-row.ts +11 -3
  137. package/src/server/grok-responses-control-frame.ts +160 -1
  138. package/src/server/index/serve-options.ts +86 -29
  139. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  140. package/src/server/index/websocket-handler.ts +6 -1
  141. package/src/server/index.ts +16 -13
  142. package/src/server/management/aside-profile-routes.ts +266 -7
  143. package/src/server/management/config-routes.ts +18 -2
  144. package/src/server/management/context.ts +3 -0
  145. package/src/server/management/integration-routes.ts +287 -5
  146. package/src/server/management/metrics-routes.ts +20 -0
  147. package/src/server/management/model-rows.ts +224 -12
  148. package/src/server/management/route-registry.ts +14 -0
  149. package/src/server/management/shared.ts +10 -3
  150. package/src/server/management/system-restart.ts +7 -2
  151. package/src/server/management/system-routes.ts +2 -0
  152. package/src/server/management/usage-aggregate-cache.ts +4 -0
  153. package/src/server/management-api.ts +2 -0
  154. package/src/server/management-auth.ts +15 -1
  155. package/src/server/readiness.ts +29 -10
  156. package/src/server/relay-eager.ts +24 -2
  157. package/src/server/relay.ts +119 -10
  158. package/src/server/request-log.ts +55 -2
  159. package/src/server/request-metrics.ts +236 -0
  160. package/src/server/responses/adapter-continuation.ts +3 -3
  161. package/src/server/responses/adapter-dispatch.ts +11 -6
  162. package/src/server/responses/compact.ts +32 -10
  163. package/src/server/responses/compaction-routing.ts +111 -0
  164. package/src/server/responses/core-codex-account.ts +8 -3
  165. package/src/server/responses/core-combo.ts +7 -7
  166. package/src/server/responses/core-normalize.ts +6 -12
  167. package/src/server/responses/core-opaque-recovery.ts +1 -0
  168. package/src/server/responses/core-options.ts +4 -0
  169. package/src/server/responses/encrypted-payload.ts +20 -2
  170. package/src/server/responses/passthrough-delivery.ts +31 -14
  171. package/src/server/responses/passthrough-dispatch.ts +36 -9
  172. package/src/server/responses/policy-fallback.ts +5 -13
  173. package/src/server/responses/request-prepare.ts +67 -17
  174. package/src/server/responses/request-send-budget.ts +5 -1
  175. package/src/server/responses/request-sidecar-auth.ts +1 -1
  176. package/src/server/responses/request-transport.ts +2 -2
  177. package/src/server/responses/run-turn-execution.ts +25 -3
  178. package/src/server/responses/sidecar-execution.ts +17 -2
  179. package/src/server/responses/ws-upstream.ts +14 -27
  180. package/src/server/responses-custom-tool-repair.ts +27 -54
  181. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  182. package/src/server/sse-payload-rewrite.ts +1 -1
  183. package/src/tray/windows-tray.ps1 +155 -3
  184. package/src/types/config.ts +11 -3
  185. package/src/types/provider.ts +18 -0
  186. package/src/types/request.ts +2 -0
  187. package/src/types/tools.ts +14 -0
  188. package/src/types.ts +1 -0
  189. package/src/vision/eligibility.ts +88 -9
  190. package/src/vision/plan.ts +34 -10
  191. package/src/web-search/executor.ts +41 -2
  192. package/src/web-search/loop.ts +6 -1
  193. package/gui/dist/assets/index-C5IebErG.js +0 -136
  194. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
2
- import { debugDroppedFrame } from "../lib/debug";
2
+ import { debugDroppedFrame, debugProviderDiagnosticLazy } from "../lib/debug";
3
3
  import { createToolCallIdAllocator } from "./tool-call-id";
4
4
  import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts";
5
5
  import type {
@@ -20,8 +20,10 @@ import { getVertexAccessToken } from "../lib/gcp-adc";
20
20
  import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
21
21
  import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors";
22
22
  import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation";
23
- import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
23
+ import { ANTIGRAVITY_REQUEST_UA, antigravitySessionAnchor, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
24
+ import { summarizeGoogleWireShape } from "./google-wire-shape";
24
25
  import { compileGoogleWireBody } from "./google-wire-compiler";
26
+ import type { GoogleToolSchemaLossReport, GoogleToolSchemaProfile } from "./google-tool-schema";
25
27
  import { identifyRoutedModel } from "./identity";
26
28
  import {
27
29
  antigravityUsesReplayCache,
@@ -737,6 +739,14 @@ function invalidGoogleShapeEvent(
737
739
  }
738
740
 
739
741
  export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter {
742
+ const toolSchemaPolicy = provider.googleToolSchemaPolicy ?? "compatible";
743
+ const toolSchemaProfile = {
744
+ endpointClass: provider.googleMode ?? "ai-studio",
745
+ } satisfies GoogleToolSchemaProfile;
746
+ const reportToolSchemaLoss = (report: GoogleToolSchemaLossReport): void => {
747
+ if (!report.lossy && report.uncertainComparisons === 0) return;
748
+ debugProviderDiagnosticLazy("google", "google-tool-schema-loss", () => ({ ...report }));
749
+ };
740
750
  // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest
741
751
  // can stash the CCA model/session for parseStream's reasoning-replay observation.
742
752
  let antigravityModel: string | undefined;
@@ -799,7 +809,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
799
809
  ...(provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist"
800
810
  ? {
801
811
  fetchResponse: (request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response> =>
802
- (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(request, ctx),
812
+ (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(
813
+ request,
814
+ ctx,
815
+ { toolSchemaProfile, toolSchemaPolicy },
816
+ ),
803
817
  formatErrorBody: (status: number, _headers: Headers, payloadText: string): string =>
804
818
  (provider.googleMode === "cloud-code-assist" ? safeAntigravityHttpErrorMessage : safeVertexHttpErrorMessage)(status, payloadText),
805
819
  }
@@ -970,7 +984,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
970
984
  const fcc = (existing.functionCallingConfig ?? {}) as Record<string, unknown>;
971
985
  draftRequest.toolConfig = { ...existing, functionCallingConfig: { ...fcc, mode: "VALIDATED" } };
972
986
  }
973
- const compiled = compileGoogleWireBody(draftRequest);
987
+ const compiled = compileGoogleWireBody(draftRequest, toolSchemaProfile, toolSchemaPolicy);
988
+ reportToolSchemaLoss(compiled.toolSchemaLossReport);
974
989
  const request = compiled.body;
975
990
  restoreGoogleToolName = compiled.restoreToolName;
976
991
  // Compile names before replay: signatures are keyed by the exact provider-visible name.
@@ -989,6 +1004,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
989
1004
  // Vertex and AI Studio share one decision. A second check here would append a
990
1005
  // duplicate nudge whenever signature sanitization reshapes the tail afterwards.
991
1006
  }
1007
+ // Opt-in structural description of the request that is about to leave (#5008).
1008
+ //
1009
+ // Passed as a BUILDER, not a value: the lazy form gates before invoking it, so a session
1010
+ // with provider debug off never pays the walk, and it evaluates the projection inside its
1011
+ // own try/catch, so a throw in here cannot turn a built request into a rejected one. The
1012
+ // projection only reads `request`, so the bytes below are the same either way.
1013
+ debugProviderDiagnosticLazy("google", "antigravity-wire-shape", () => summarizeGoogleWireShape(request, {
1014
+ sessionAnchor: antigravitySessionAnchor(parsed),
1015
+ // Signed at translation time, from client history or the durable store. The
1016
+ // Antigravity session cache signs afterwards, inside applyAntigravityReplay, and the
1017
+ // projection attributes that remainder to the cache rather than to this count.
1018
+ historySignedCalls: replayedCallIds.length,
1019
+ replayScopeBound: parsed._reasoningReplayScope !== undefined,
1020
+ }));
992
1021
  const envelope = {
993
1022
  model: wireModelId,
994
1023
  // The envelope's `userAgent` field is a protocol constant ("antigravity"), distinct from
@@ -1006,7 +1035,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1006
1035
  }
1007
1036
 
1008
1037
  if (provider.googleMode === "vertex") {
1009
- const compiled = compileGoogleWireBody(body);
1038
+ const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy);
1039
+ reportToolSchemaLoss(compiled.toolSchemaLossReport);
1010
1040
  restoreGoogleToolName = compiled.restoreToolName;
1011
1041
  const vertexProject = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || "api-key";
1012
1042
  const vertexLocation = provider.location || process.env.GOOGLE_CLOUD_LOCATION || "global";
@@ -1052,7 +1082,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1052
1082
  if (!apiKey) throw new Error("google (AI Studio) requires a non-empty API key");
1053
1083
  headers["x-goog-api-key"] = apiKey;
1054
1084
 
1055
- const compiled = compileGoogleWireBody(body);
1085
+ const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy);
1086
+ reportToolSchemaLoss(compiled.toolSchemaLossReport);
1056
1087
  restoreGoogleToolName = compiled.restoreToolName;
1057
1088
  return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
1058
1089
  },
@@ -13,7 +13,9 @@ import {
13
13
  export const OPENAI_CHAT_IMAGE_BASE64_BUDGET = 3_670_016; // 3.5MiB
14
14
 
15
15
  export interface NormalizeOpenAIChatImagesOptions
16
- extends Pick<NormalizeOptions, "encode" | "tierBias" | "validate"> {}
16
+ extends Pick<NormalizeOptions, "encode" | "tierBias" | "validate"> {
17
+ abortSignal?: AbortSignal;
18
+ }
17
19
 
18
20
  /** Whether `value` is a plain object, so message and part shapes can be walked safely. */
19
21
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -297,7 +297,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
297
297
  };
298
298
  };
299
299
  if (hasShrinkableOpenAIChatImages(messages)) {
300
- return normalizeOpenAIChatImages(messages, { tierBias: incoming?.imageTierBias }).then(finish, finish);
300
+ const imageOptions = { tierBias: incoming?.imageTierBias, abortSignal: incoming?.abortSignal };
301
+ return normalizeOpenAIChatImages(messages, imageOptions).then(finish, error => {
302
+ if (incoming?.abortSignal?.aborted) throw error;
303
+ return finish();
304
+ });
301
305
  }
302
306
  return finish();
303
307
  },
@@ -101,14 +101,17 @@ export function preferConfiguredHostedTools(
101
101
  }
102
102
 
103
103
  let input = body.input;
104
- const strippedAdditionalToolsIndices = new Set<number>();
104
+ // Indices arrive in increasing order, so the first stripped container is already
105
+ // the minimum — tracking it directly avoids spreading an attacker-sized Set into
106
+ // Math.min's argument list.
107
+ let firstStrippedAdditionalToolsIndex: number | undefined;
105
108
  if (Array.isArray(body.input)) {
106
109
  let nestedChanged = false;
107
110
  const mappedInput = body.input.map((item, index) => {
108
111
  if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
109
112
  const nestedTools = stripGroup(item.tools);
110
113
  if (nestedTools === item.tools) return item;
111
- strippedAdditionalToolsIndices.add(index);
114
+ firstStrippedAdditionalToolsIndex ??= index;
112
115
  nestedChanged = true;
113
116
  return { ...item, tools: nestedTools };
114
117
  });
@@ -127,17 +130,16 @@ export function preferConfiguredHostedTools(
127
130
  || (Array.isArray(input) && input.some(item => isPlainObject(item)
128
131
  && item.type === "additional_tools"
129
132
  && hasHostedImageGenTool(item.tools)));
130
- if ((strippedTopLevelImageGenTool || strippedAdditionalToolsIndices.size > 0) && !hasHostedImageGenDeclaration) {
133
+ if ((strippedTopLevelImageGenTool || firstStrippedAdditionalToolsIndex !== undefined) && !hasHostedImageGenDeclaration) {
131
134
  if (strippedTopLevelImageGenTool && Array.isArray(tools)) {
132
135
  tools = [...tools, { type: HOSTED_IMAGE_GENERATION_TOOL }];
133
- } else if (strippedAdditionalToolsIndices.size > 0 && Array.isArray(input)) {
136
+ } else if (firstStrippedAdditionalToolsIndex !== undefined && Array.isArray(input)) {
134
137
  // Restore into the FIRST stripped container only. Tool declarations are
135
138
  // request-scoped, not container-scoped — the containers are separate carriers for
136
139
  // one tool set, so a single hosted declaration covers the request. An earlier
137
140
  // revision restored into every stripped container and put `image_generation` on
138
141
  // the wire twice; review caught it.
139
- const firstStripped = Math.min(...strippedAdditionalToolsIndices);
140
- input = input.map((item, index) => index === firstStripped
142
+ input = input.map((item, index) => index === firstStrippedAdditionalToolsIndex
141
143
  && isPlainObject(item)
142
144
  && Array.isArray(item.tools)
143
145
  ? { ...item, tools: [...item.tools, { type: HOSTED_IMAGE_GENERATION_TOOL }] }
@@ -18,6 +18,7 @@ import type { TranslatorBudget } from "../../lib/translator-budget";
18
18
  import { rewriteRoutedCustomToolsForUpstream } from "../../responses/custom-tool-compat";
19
19
  import { rewriteRoutedToolSearchForUpstream } from "../../responses/tool-search-compat";
20
20
  import { rewriteRoutedNamespaceToolsForUpstream } from "../../responses/namespace-tool-compat";
21
+ import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair";
21
22
  import { preparePlaintextV2AgentMessages } from "../../responses/plaintext-v2-agent-messages";
22
23
  import { isMetaAiResponsesDestination, rewriteMuseToolNamesForUpstream } from "../../responses/muse-tool-name-alias";
23
24
  import { openaiResponsesUrl } from "../openai-responses-url";
@@ -31,7 +32,7 @@ import {
31
32
  import {
32
33
  createAdapterTierMetadata,
33
34
  } from "../../providers/fastwire";
34
- import { mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripUnsupportedReasoningSummaryDelivery } from "./reasoning";
35
+ import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripUnsupportedReasoningSummaryDelivery } from "./reasoning";
35
36
  import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored } from "./request-strips";
36
37
  import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache";
37
38
  import { isPlainObject } from "./internal";
@@ -295,6 +296,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
295
296
  if (forward || stateless || pairedToolResults) {
296
297
  outBody = repairOrphanedInputItems(outBody, unexpandedMiss, synthesizeMissingCallOutputs);
297
298
  }
299
+ if (provider.dropResponsesReasoningItems === true) {
300
+ outBody = dropResponsesReasoningInputItems(outBody);
301
+ }
298
302
  if (adjacentToolResults) {
299
303
  outBody = normalizeResponsesToolResultAdjacency(outBody);
300
304
  }
@@ -335,6 +339,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
335
339
  // every turn, and a strict parser rejects the whole request over the missing key —
336
340
  // `queries` for DeepSeek (#930), `query` for Console Go (#3071).
337
341
  outBody = backfillWebSearchQueries(outBody);
342
+ // #5095: a conversation that already contains a `default.`-prefixed call name is refused by
343
+ // the upstream `^[a-zA-Z0-9_-]+$` name pattern on every later turn that replays it, so the
344
+ // task cannot be compacted or continued at all. Repair the replayed item here, before the
345
+ // canonical-destination split below, because the reported failure was a side chat on a plain
346
+ // OpenAI model inheriting history a routed provider had damaged.
347
+ outBody = repairLegacyDottedToolCallNames(outBody);
338
348
  if (!isCanonicalOpenAiForwardProvider(provider)) {
339
349
  outBody = stripInternalChatMessageMetadataPassthrough(outBody);
340
350
  // The same class of private field, one level up, but keyed on the DESTINATION rather than
@@ -397,14 +407,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
397
407
  // Last, so promoted namespace children are also cleared of Codex-private fields.
398
408
  outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
399
409
  }
400
- if (!forward) outBody = normalizeOpenCodeGoAdditionalTools(outBody, url);
410
+ if (!forward) {
411
+ outBody = normalizeOpenCodeGoAdditionalTools(outBody, url, parsed._replayPrefixLen);
412
+ }
401
413
  // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would
402
414
  // let a noncanonical custom forward provider skip this rewrite while the server still routes
403
415
  // it as a summarizer turn (#422). The compaction body build removes the tool surface and must
404
416
  // therefore be the last routed transform that may depend on those declarations. Structural
405
417
  // sanitizers below can still run after it.
406
418
  outBody = normalizeResponsesCodeMode(outBody, parsed, provider);
407
- if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
419
+ if (parsed._compactionRequest === true && (!isCanonicalOpenAiForwardProvider(provider) || parsed._portableCompaction === true)) {
408
420
  outBody = buildRoutedCompactionBody(outBody);
409
421
  }
410
422
  // Run after routed compaction so nested input_image parts are replaced before a malformed
@@ -4,6 +4,13 @@ import { OCX_REASONING_PREFIX } from "../../responses/reasoning-envelope";
4
4
  import { configuredReasoningEfforts, mapReasoningEffort, modelRecordValue } from "../../reasoning-effort";
5
5
  import { isPlainObject } from "./internal";
6
6
 
7
+ /** Drop only replayed Responses reasoning items; all other continuation input stays untouched. */
8
+ export function dropResponsesReasoningInputItems(body: unknown): unknown {
9
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
10
+ const input = body.input.filter(item => !isPlainObject(item) || item.type !== "reasoning");
11
+ return input.length === body.input.length ? body : { ...body, input };
12
+ }
13
+
7
14
  /**
8
15
  * Sanitize reasoning input by field policy, not by preserving each item's shape. Retaining a
9
16
  * native `encrypted_content` guarantees only that blob value: `status` is always removed;
@@ -3,7 +3,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
3
3
  }
4
4
 
5
5
  /** Console Go accepts public tools but rejects the private additional_tools input wrapper. */
6
- export function normalizeOpenCodeGoAdditionalTools(body: unknown, responseUrl: string): unknown {
6
+ export function normalizeOpenCodeGoAdditionalTools(
7
+ body: unknown,
8
+ responseUrl: string,
9
+ replayPrefixLength = 0,
10
+ ): unknown {
7
11
  let destination: URL;
8
12
  try {
9
13
  destination = new URL(responseUrl);
@@ -20,10 +24,16 @@ export function normalizeOpenCodeGoAdditionalTools(body: unknown, responseUrl: s
20
24
 
21
25
  const input: unknown[] = [];
22
26
  const promoted: unknown[] = [];
27
+ const currentTurnStart = Number.isFinite(replayPrefixLength)
28
+ ? Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength)))
29
+ : 0;
23
30
  let changed = false;
24
- for (const item of body.input) {
31
+ for (const [index, item] of body.input.entries()) {
25
32
  if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
26
33
  changed = true;
34
+ // Replayed wrappers are conversation history, not authority for the current request.
35
+ // Go cannot accept the wrapper itself, so remove it without promoting its catalog.
36
+ if (index < currentTurnStart) continue;
27
37
  // Custom/search/namespace lowering already owns identity and deduplication. This pass
28
38
  // only moves declarations, including hosted tools that intentionally have no name.
29
39
  for (const tool of item.tools) promoted.push(tool);
package/src/bridge/sse.ts CHANGED
@@ -17,11 +17,10 @@ import {
17
17
  } from "../lib/errors";
18
18
  import { redactSecretString } from "../lib/redact";
19
19
  import {
20
- freeformFallbackKeys,
21
20
  mayBecomePatchEnvelope,
22
21
  repairFreeformToolInput,
23
- unwrapFreeformToolInput,
24
22
  } from "../responses/apply-patch-envelope";
23
+ import { progressiveFreeformInput } from "../responses/progressive-freeform-input";
25
24
  import { encodeCompactionSummary } from "../responses/compaction";
26
25
  import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
27
26
  import { isTruncatedStopReason, truncationReasonFor } from "../responses/truncated-stop-reason";
@@ -153,153 +152,6 @@ export function bridgeToResponsesSSE(
153
152
  ? compileCodeModeHelperInput(args, helper, codeModeHelperName ?? toolName)
154
153
  : repairFreeformToolInput(args, toolName, namespace);
155
154
  };
156
- // Best-effort unwrap of a PARTIAL freeform arg buffer for live input streaming
157
- // (`response.custom_tool_call_input.delta` — codex-rs uses it for UI preview only;
158
- // the completed custom_tool_call item stays authoritative). Compact `{"input":"...`
159
- // buffers get their string value progressively unescaped; anything else streams raw.
160
- const JSON_WHITESPACE = new Set([" ", "\t", "\n", "\r"]);
161
- type WrapperOpening =
162
- | { state: "none" }
163
- | { state: "prefix" }
164
- | { state: "open"; valueStart: number };
165
- /**
166
- * Where the string value of `{"<key>":"` begins, tolerating the insignificant whitespace
167
- * `JSON.parse` accepts.
168
- *
169
- * The earlier form of this compared the buffer against the compact literal `{"key":"`, so a
170
- * wrapper written with spaces or newlines matched no prefix at all, streamed as raw JSON
171
- * deltas and then completed as the unwrapped body. That is the same delta/completion
172
- * disagreement #5047 closed for compact wrappers, reached through a different spelling:
173
- * `unwrapFreeformToolInput` reads the completed text with `JSON.parse`, which does not care
174
- * how the object is laid out, so neither can the streaming side.
175
- */
176
- const wrapperOpening = (args: string, key: string): WrapperOpening => {
177
- let index = 0;
178
- for (const token of ["{", `"${key}"`, ":", '"']) {
179
- while (index < args.length && JSON_WHITESPACE.has(args[index]!)) index++;
180
- if (index >= args.length) return { state: "prefix" };
181
- for (const expected of token) {
182
- if (index >= args.length) return { state: "prefix" };
183
- if (args[index] !== expected) return { state: "none" };
184
- index++;
185
- }
186
- }
187
- return { state: "open", valueStart: index };
188
- };
189
- /**
190
- * Whether a body could still grow into one complete outer Markdown fence.
191
- *
192
- * `stripMarkdownCodeFence` removes such a fence at completion for exactly the two tools that
193
- * own the grammar, so a fenced body's streamed bytes and its completed input disagree unless
194
- * the stream holds. A buffer that does not open with a fence can never acquire one, so
195
- * ordinary bodies are unaffected; a buffer that does keeps its preview suppressed for the
196
- * whole call, because a closing fence can still be followed by more text that withdraws it.
197
- */
198
- const mayBecomeFencedBody = (text: string, toolName: string): boolean => {
199
- if (toolName !== "exec" && toolName !== "apply_patch") return false;
200
- const head = text.trimStart();
201
- if (head === "") return true;
202
- return head.startsWith("```") || "```".startsWith(head);
203
- };
204
- /** The two-character escapes JSON defines, and nothing else. */
205
- const JSON_ESCAPES = new Map<string, string>([
206
- ['"', '"'], ["\\", "\\"], ["/", "/"],
207
- ["b", "\b"], ["f", "\f"], ["n", "\n"], ["r", "\r"], ["t", "\t"],
208
- ]);
209
- const LOW_SURROGATE_ESCAPE = /^\\u[dD][c-fC-F][0-9a-fA-F]{2}$/;
210
- /**
211
- * The decoded prefix of a JSON string body, stopping at the first byte it cannot resolve.
212
- *
213
- * Every stop is a hold rather than a guess, because `JSON.parse` decides the completed value
214
- * and anything invented here would be retracted. Three of them are not obvious:
215
- *
216
- * An escape JSON does not define makes the whole wrapper unparseable no matter what arrives
217
- * next, so completion falls back to the raw text. Returning `null` for that case stops the
218
- * preview rather than continuing to decode a value the completed item will never carry.
219
- * `\\b` and `\\f` are defined, and were previously decoded to the letters b and f.
220
- *
221
- * A lone high surrogate is not a character. Emitting one alone puts an unpaired code unit in a
222
- * delta the client has to decode by itself, so the pair is emitted together or not at all.
223
- */
224
- const decodeJsonStringPrefix = (body: string): string | null => {
225
- let out = "";
226
- for (let i = 0; i < body.length; i++) {
227
- const c = body[i];
228
- if (c === '"') break; // unescaped closing quote: value complete
229
- if (c !== "\\") { out += c; continue; }
230
- const n = body[i + 1];
231
- if (n === undefined) break; // escape split across chunks: wait for more
232
- if (n === "u") {
233
- const hex = body.slice(i + 2, i + 6);
234
- if (hex.length < 4) break; // split across chunks: wait for more
235
- if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null; // never parses
236
- const code = parseInt(hex, 16);
237
- if (code >= 0xd800 && code <= 0xdbff) {
238
- const low = body.slice(i + 6, i + 12);
239
- if (!LOW_SURROGATE_ESCAPE.test(low)) break;
240
- out += String.fromCharCode(code, parseInt(low.slice(2), 16));
241
- i += 11;
242
- continue;
243
- }
244
- out += String.fromCharCode(code);
245
- i += 5;
246
- continue;
247
- }
248
- const escaped = JSON_ESCAPES.get(n);
249
- if (escaped === undefined) return null; // never parses
250
- out += escaped;
251
- i += 1;
252
- }
253
- return out;
254
- };
255
- /**
256
- * The value to stream so far, or `null` to HOLD because nothing can be decided yet.
257
- *
258
- * `input` is decidable from its prefix: `unwrapFreeformToolInput` returns it whenever the
259
- * key is present, whatever else the object carries, so its value can be unescaped
260
- * progressively and never retracted.
261
- *
262
- * One case escapes that claim and is accepted rather than fixed: a duplicate `input` key.
263
- * `JSON.parse` keeps the last one, so `{"input":"a","input":"b"}` streams a and completes with
264
- * b. Closing it means holding every canonical wrapper until its object parses, which is the
265
- * progressive streaming this path exists to provide. The completed item stays authoritative,
266
- * and no model emits a duplicate key in practice.
267
- *
268
- * A wrapper that turns invalid AFTER streaming has committed has the same shape and the same
269
- * answer. `{"input":"a` followed by `\\qb"}` has already published a when the undefined escape
270
- * arrives, and completion returns the raw text because nothing parses. The preview stops
271
- * there: no rewind, and no decoded text the completed item does not contain. Bounding the
272
- * damage is what is available without giving up progressive streaming, and the args are
273
- * unusable in that case whichever representation wins.
274
- *
275
- * A fallback key is not. It only unwraps when it is the SINGLE string field, and a second
276
- * key can still arrive — so a value emitted early would have to be taken back. That is the
277
- * rewind this holds instead: stream nothing until the object closes, then publish the one
278
- * repaired body. The routed passthrough in `responses-custom-tool-repair.ts` already holds
279
- * any object prefix for the same reason (#5047).
280
- */
281
- const freeformPartialInput = (args: string, toolName: string): string | null => {
282
- const openings = ["input", ...freeformFallbackKeys(toolName)]
283
- .map(key => ({ key, opening: wrapperOpening(args, key) }));
284
- const canonical = openings[0]!.opening;
285
- if (canonical.state === "open") {
286
- const decoded = decodeJsonStringPrefix(args.slice(canonical.valueStart));
287
- if (decoded === null) return null;
288
- return mayBecomeFencedBody(decoded, toolName) ? null : decoded;
289
- }
290
- if (openings.some(entry => entry.opening.state === "open")) {
291
- // Committed to a fallback wrapper. Undecidable until the object is complete.
292
- try {
293
- JSON.parse(args);
294
- } catch {
295
- return null;
296
- }
297
- return unwrapFreeformToolInput(args, toolName);
298
- }
299
- // Still an ambiguous prefix of some wrapper: which wrapper, if any, is not known yet.
300
- if (openings.some(entry => entry.opening.state === "prefix")) return null;
301
- return mayBecomeFencedBody(args, toolName) ? null : args;
302
- };
303
155
  // tool_search_call carries arguments as a JSON object ({query, limit}); parse the model's arg string.
304
156
  const parseArgsObj = (args: string): Record<string, unknown> => {
305
157
  try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
@@ -1199,7 +1051,7 @@ export function bridgeToResponsesSSE(
1199
1051
  });
1200
1052
  }
1201
1053
  if (currentToolCall.freeform && !currentToolCall.codeModeHelperName) {
1202
- // `freeformPartialInput` holds while the buffer is still an ambiguous prefix
1054
+ // `progressiveFreeformInput` holds while the buffer is still an ambiguous prefix
1203
1055
  // of a JSON wrapper; otherwise stream only the unwrapped input suffix, never
1204
1056
  // rewinding on a mode flip.
1205
1057
  //
@@ -1209,7 +1061,7 @@ export function bridgeToResponsesSSE(
1209
1061
  // is the same disagreement in the other direction.
1210
1062
  const ownsFreeformGrammar = currentToolCall.namespace === undefined
1211
1063
  || currentToolCall.namespace === "functions";
1212
- const full = freeformPartialInput(
1064
+ const full = progressiveFreeformInput(
1213
1065
  currentToolCall.args,
1214
1066
  ownsFreeformGrammar ? currentToolCall.name : "",
1215
1067
  );
@@ -403,16 +403,16 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
403
403
  // shared kernel, and absent is a server that does not speak this field at all. Collapsing
404
404
  // false into absent would render the live feature as an unknown capability.
405
405
  const inert = typeof settings.inert === "boolean" ? settings.inert : null;
406
- // A stored threshold only steers selection once the pool consumes it, which is exactly
407
- // what `inert: false` reports.
408
- const enabled = inert === false && storedThreshold !== null;
406
+ // A positive stored threshold only steers selection once the pool consumes it, which is
407
+ // exactly what `inert: false` reports. Zero remains the explicit disabled value.
408
+ const enabled = inert === false && storedThreshold !== null && storedThreshold > 0;
409
409
  if (wantsJson) {
410
410
  console.log(JSON.stringify({ provider: name, autoSwitchThreshold: storedThreshold, enabled, poolEnabled, inert }, null, 2));
411
411
  } else {
412
412
  const value = storedThreshold === null ? "unset" : `${storedThreshold}%`;
413
413
  const state = inert === false ? (enabled ? "on" : "off") : inert === true ? "inactive" : "unavailable";
414
414
  const why = inert === false
415
- ? (enabled ? "applied by this pool" : "no threshold stored")
415
+ ? (enabled ? "applied by this pool" : storedThreshold === 0 ? "usage-based switching disabled" : "no threshold stored")
416
416
  : inert === true ? "not applied by this pool" : "threshold support is unknown";
417
417
  console.log(`auto-switch: ${state} (stored threshold ${value}; ${why})`);
418
418
  }
@@ -945,9 +945,9 @@ export type StartOwnerDecision = "refuse" | "service-stay-out" | "sibling";
945
945
  *
946
946
  * The #3106 guard exists so a bare `start` cannot shadow a healthy configured-port
947
947
  * proxy with an ephemeral-port copy. An interactive `--port X` naming a DIFFERENT
948
- * port than the live proxy's is an explicit sibling request, not that shadow — and
949
- * refusing it also broke every spawned-launcher test on a machine running a real
950
- * proxy, because the probe reaches the machine-global port across sandbox homes.
948
+ * port than the live proxy's is an explicit sibling request, not that shadow. The
949
+ * state-directory spend-ledger lease makes the final same-home refusal; keeping this
950
+ * decision allows isolated homes on one machine to remain independent.
951
951
  * The service wrapper always passes the configured port and keeps its exact
952
952
  * stay-out-of-the-way semantics: it never takes the sibling path.
953
953
  */
package/src/cli/doctor.ts CHANGED
@@ -1505,8 +1505,16 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1505
1505
  // cannot break a legitimately green pipeline.
1506
1506
 
1507
1507
  console.log("\nCodex agent role files");
1508
- const tomlFallbackRoles = scanCodexAgentRolesWithTomlModelFallback(resolveCodexHomeDirImpl());
1509
- if (tomlFallbackRoles.length === 0) {
1508
+ let roleScanError: unknown;
1509
+ const tomlFallbackRoles = scanCodexAgentRolesWithTomlModelFallback(
1510
+ resolveCodexHomeDirImpl(),
1511
+ cause => {
1512
+ roleScanError = cause;
1513
+ },
1514
+ );
1515
+ if (roleScanError) {
1516
+ console.log(` [WARN] unable to scan $CODEX_HOME/agents/*.toml: ${String(roleScanError)}`);
1517
+ } else if (tomlFallbackRoles.length === 0) {
1510
1518
  console.log(" ok no per-role model_fallback fields in $CODEX_HOME/agents/*.toml");
1511
1519
  } else {
1512
1520
  console.log(` [WARN] ${tomlFallbackRoles.length} agent role file${tomlFallbackRoles.length === 1 ? "" : "s"} contain${tomlFallbackRoles.length === 1 ? "s" : ""} \`model_fallback\`: ${tomlFallbackRoles.join(", ")}`);
@@ -1514,13 +1522,24 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1514
1522
  }
1515
1523
  // opencodex does not write these files; the Codex desktop external-agent import does, and it
1516
1524
  // drops the model pin on the way in. Observe-only: doctor never repairs or removes them.
1517
- const unpinnedDerivedRoles = scanOpencodexDerivedCodexAgentRolesWithoutModelPin(resolveCodexHomeDirImpl());
1518
- if (unpinnedDerivedRoles.length === 0) {
1519
- console.log(" ok every opencodex-derived role file in $CODEX_HOME/agents/*.toml pins a model");
1520
- } else {
1521
- console.log(` [WARN] ${unpinnedDerivedRoles.length} opencodex-derived role file${unpinnedDerivedRoles.length === 1 ? "" : "s"} without a \`model\` pin: ${unpinnedDerivedRoles.map(role => `${role}.toml`).join(", ")}`);
1522
- console.log(" Codex runs these roles on the parent model, so a spawn records one role and another model. The `ocx-route` directive in the file cannot pin them: it is honoured only on the Claude Code `/v1/messages` path and is inert on `/v1/responses`.");
1523
- console.log(" Add `model = \"<id>\"` to each file, or remove them. They usually come from the Codex desktop external-agent import of ~/.claude/agents/ocx-*.md; set `[desktop] external-agent-import-sync-item-types` with `SUBAGENTS = false` to stop it recreating them.");
1525
+ // Both role scans share the same directory listing, so a failure above already reported the
1526
+ // cause; skip the second scan rather than warn twice or print a false "ok".
1527
+ if (!roleScanError) {
1528
+ const unpinnedDerivedRoles = scanOpencodexDerivedCodexAgentRolesWithoutModelPin(
1529
+ resolveCodexHomeDirImpl(),
1530
+ cause => {
1531
+ roleScanError = cause;
1532
+ },
1533
+ );
1534
+ if (roleScanError) {
1535
+ console.log(` [WARN] unable to scan $CODEX_HOME/agents/*.toml: ${String(roleScanError)}`);
1536
+ } else if (unpinnedDerivedRoles.length === 0) {
1537
+ console.log(" ok every opencodex-derived role file in $CODEX_HOME/agents/*.toml pins a model");
1538
+ } else {
1539
+ console.log(` [WARN] ${unpinnedDerivedRoles.length} opencodex-derived role file${unpinnedDerivedRoles.length === 1 ? "" : "s"} without a \`model\` pin: ${unpinnedDerivedRoles.map(role => `${role}.toml`).join(", ")}`);
1540
+ console.log(" Codex runs these roles on the parent model, so a spawn records one role and another model. The `ocx-route` directive in the file cannot pin them: it is honoured only on the Claude Code `/v1/messages` path and is inert on `/v1/responses`.");
1541
+ console.log(" Add `model = \"<id>\"` to each file, or remove them. They usually come from the Codex desktop external-agent import of ~/.claude/agents/ocx-*.md; set `[desktop] external-agent-import-sync-item-types` with `SUBAGENTS = false` to stop it recreating them.");
1542
+ }
1524
1543
  }
1525
1544
 
1526
1545
  const dual = collectWslDualInstall();
package/src/cli/hub.ts CHANGED
@@ -228,8 +228,9 @@ export function hubInviteCommand(
228
228
  managementUrl: string,
229
229
  clients: OcxConnectedClientId[],
230
230
  ): string {
231
- const clientsFlag = clients.length > 0 ? ` --clients ${clients.join(",")}` : "";
232
- return `echo '${code}' | ocx connect ${dataUrl} --management-url ${managementUrl}${clientsFlag} --pairing-code-stdin`;
231
+ const shellQuote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`;
232
+ const clientsFlag = clients.length > 0 ? ` --clients ${shellQuote(clients.join(","))}` : "";
233
+ return `echo ${shellQuote(code)} | ocx connect ${shellQuote(dataUrl)} --management-url ${shellQuote(managementUrl)}${clientsFlag} --pairing-code-stdin`;
233
234
  }
234
235
 
235
236
  async function runInvite(args: string[], deps: HubCommandDeps): Promise<number> {
package/src/cli/index.ts CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  } from "./tray-proxy";
79
79
  import { requestBoundSystemRestart } from "./system-restart-client";
80
80
  import { installCrashGuards } from "../lib/crash-guard";
81
+ import { SpendLedgerOwnerError } from "../lib/spend-ledger-owner";
81
82
  import { redactUrlForLog } from "../lib/redact";
82
83
  import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch";
83
84
  import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
@@ -418,8 +419,8 @@ async function handleStart(options: { block?: boolean } = {}) {
418
419
  // to bake the service (observed: a probe on 10198 left the service pinned there).
419
420
  siblingStart = true;
420
421
  console.warn(
421
- `Proxy already running on port ${owner.live.port}; starting a second instance on requested port ${requestedPort}. `
422
- + `The new instance takes over this home's pid/runtime records and Codex config while it runs.`,
422
+ `Proxy already running on port ${owner.live.port}; requested a second instance on port ${requestedPort}. `
423
+ + `Startup continues only for an independent OPENCODEX_HOME; one state directory has one spend-ledger writer.`,
423
424
  );
424
425
  }
425
426
 
@@ -463,6 +464,10 @@ async function handleStart(options: { block?: boolean } = {}) {
463
464
  scheduleCatalogPrewarm();
464
465
  break;
465
466
  } catch (err) {
467
+ if (err instanceof SpendLedgerOwnerError) {
468
+ console.error(`❌ ${err.message}`);
469
+ process.exit(1);
470
+ }
466
471
  if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err;
467
472
  if (requestedPort !== undefined) {
468
473
  console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`);
@@ -41,7 +41,7 @@ import type {
41
41
  } from "../clients/config-export";
42
42
  import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog";
43
43
  import { commandInvocation } from "../lib/win-exec";
44
- import { configuredAdminToken } from "../lib/admin-secrets";
44
+ import { configuredAdminToken, opencodeCatalogToken } from "../lib/admin-secrets";
45
45
  import { localManagementOrigin } from "../lib/local-destinations";
46
46
  import { directLocalHttpFetch } from "../server/direct-local-http";
47
47
  import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
@@ -684,7 +684,7 @@ export async function cmdOpencode(args: string[]): Promise<number> {
684
684
  try {
685
685
  const managementToken = configuredAdminToken();
686
686
  if (!managementToken) throw new Error("No local admin token is available; check the running proxy's home.");
687
- proxyModels = await fetchOpencodeProxyModels(live, managementToken, {
687
+ proxyModels = await fetchOpencodeProxyModels(live, opencodeCatalogToken(managementToken), {
688
688
  managementOrigin: localManagementOrigin({ ...startupConfig, hostname: live.hostname }, live.port),
689
689
  });
690
690
  } catch (error) {