@bitkyc08/opencodex 2.56.0 → 2.58.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 (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. package/src/web-search/xai-executor.ts +1 -1
@@ -1,3 +1,4 @@
1
+ import { isNonReplayableResponse } from "../../lib/upstream-retry";
1
2
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
2
3
  import type { PreparedResponsesRequest } from "./request-prepare";
3
4
  import type { ResponsesTransport } from "./request-transport";
@@ -11,7 +12,6 @@ import { trackStreamLifetime } from "../lifecycle";
11
12
  import {
12
13
  recordAdapterReasoning,
13
14
  recordAdapterTier,
14
- noteAttemptSend,
15
15
  sealRequestAttemptIdentity,
16
16
  recordAttemptCredentialSource,
17
17
  } from "../request-log";
@@ -72,7 +72,12 @@ import { consumeComboFailure } from "./core-combo-failure";
72
72
  import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
73
73
  import { isFixedCodexAccount } from "./core-codex-account";
74
74
  import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
75
- import { isCyberPolicyCode, CYBER_POLICY_FALLBACK_MESSAGE, CYBER_POLICY_ERROR_CODE } from "../../lib/errors";
75
+ import {
76
+ isCyberPolicyCode,
77
+ CYBER_POLICY_FALLBACK_MESSAGE,
78
+ CYBER_POLICY_ERROR_CODE,
79
+ SEND_BUDGET_EXHAUSTED_CODE,
80
+ } from "../../lib/errors";
76
81
  import { resolveClientRetryAfter } from "../../lib/retry-after";
77
82
  import { cancelBodyOnAbort } from "../../lib/abort";
78
83
 
@@ -115,11 +120,12 @@ export async function prepareAdapterExchange(
115
120
  | "genericFailoverAccountId"
116
121
  | "genericFailovers"
117
122
  | "applyFailoverSnapshot"
123
+ | "noteRoutedAttemptSend"
118
124
  >,
119
125
  responseEffects: Pick<ResponsesEffects, "cancelResponseCompletion" | "notifyResponseComplete" | "refreshRequestToolAliases">,
120
126
  sendBudgetState: Pick<
121
127
  ResponsesSendBudget,
122
- | "adapterSendBudget"
128
+ | "adapterDispatchBudget"
123
129
  | "noteAdapterPhysicalSend"
124
130
  | "remainingTransientSendBudget"
125
131
  | "noteTransientSends"
@@ -127,6 +133,7 @@ export async function prepareAdapterExchange(
127
133
  | "recoveryClassFor"
128
134
  | "sendBudgetExhausted"
129
135
  | "reserveCredentialHop"
136
+ | "pendingHopPermit"
130
137
  >,
131
138
  ) {
132
139
  const { options, config, logCtx, req } = requestContext;
@@ -151,7 +158,7 @@ export async function prepareAdapterExchange(
151
158
  } = requestState;
152
159
  const { cancelResponseCompletion, notifyResponseComplete, refreshRequestToolAliases } = responseEffects;
153
160
  const {
154
- adapterSendBudget,
161
+ adapterDispatchBudget,
155
162
  noteAdapterPhysicalSend,
156
163
  remainingTransientSendBudget,
157
164
  noteTransientSends,
@@ -272,15 +279,16 @@ export async function prepareAdapterExchange(
272
279
  let upstreamResponse: Response;
273
280
  try {
274
281
  if (transportState.activeAdapter.fetchResponse) {
275
- noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
282
+ transportState.noteRoutedAttemptSend(inputTokenEstimate);
276
283
  await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
277
284
  upstreamResponse = await transportState.activeAdapter.fetchResponse(builtInitialRequest, {
278
285
  abortSignal: upstream.signal,
279
286
  timeoutMs: connectMs,
280
- sendBudget: adapterSendBudget,
287
+ sendBudget: adapterDispatchBudget,
281
288
  onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send),
282
289
  stream: parsed.stream,
283
290
  executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
291
+ pacingSlotAcquired: true,
284
292
  dispatchOverride: oauthDispatch(builtInitialRequest),
285
293
  providerName: route.providerName,
286
294
  modelId: route.modelId,
@@ -300,7 +308,7 @@ export async function prepareAdapterExchange(
300
308
  : fetchWithResetRetry;
301
309
  upstreamResponse = await fetchWithRetryPolicy(
302
310
  recovery => {
303
- noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
311
+ transportState.noteRoutedAttemptSend(inputTokenEstimate, recovery);
304
312
  return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
305
313
  method: builtInitialRequest.method,
306
314
  headers: builtInitialRequest.headers,
@@ -331,6 +339,13 @@ export async function prepareAdapterExchange(
331
339
  cleanupUpstreamAbort();
332
340
  upstream.abort();
333
341
  if (options.abortSignal?.aborted) return clientCancelledResponse();
342
+ // A budget refusal is a decision this process made, not an upstream fault. Reporting it as
343
+ // 502 does more than mislabel it: the Codex client retries 5xx and does not retry a 429, so
344
+ // blaming the provider makes the caller send the whole turn again -- the amplification this
345
+ // budget exists to stop. The passthrough path has answered 429 here since #4546.
346
+ if (err instanceof SendBudgetExhaustedError) {
347
+ return formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, err.message);
348
+ }
334
349
  const msg = describeUpstreamConnectFailure(err, connectMs);
335
350
  return formatErrorResponse(502, "upstream_error", msg);
336
351
  } finally {
@@ -412,10 +427,10 @@ export async function prepareAdapterExchange(
412
427
  logCtx.providerAdapter = transportState.activeAdapter.name;
413
428
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
414
429
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
415
- noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
416
430
  try {
417
431
  try {
418
432
  if (transportState.activeAdapter.fetchResponse) {
433
+ transportState.noteRoutedAttemptSend(retryEstimate, recovery);
419
434
  await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
420
435
  // The dispatch boundary is HERE, not before the pacing wait: that wait can reject for
421
436
  // an abort, a saturated queue, an expired slot or a removed provider, and none of
@@ -425,10 +440,11 @@ export async function prepareAdapterExchange(
425
440
  return await transportState.activeAdapter.fetchResponse(retryRequest, {
426
441
  abortSignal: upstream.signal,
427
442
  timeoutMs: connectMs,
428
- sendBudget: adapterSendBudget,
443
+ sendBudget: adapterDispatchBudget,
429
444
  onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send),
430
445
  stream: parsed.stream,
431
446
  executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
447
+ pacingSlotAcquired: true,
432
448
  dispatchOverride: oauthDispatch(retryRequest),
433
449
  providerName: route.providerName,
434
450
  modelId: route.modelId,
@@ -461,6 +477,7 @@ export async function prepareAdapterExchange(
461
477
  if (refetchAllowance?.permit && !refetchAllowance.permit.use()) {
462
478
  throw new SendBudgetExhaustedError(safeHostLabel(retryRequest.url));
463
479
  }
480
+ transportState.noteRoutedAttemptSend(retryEstimate, recoveryKind ?? recovery);
464
481
  // Same boundary on the helper path: the thunk is what reaches the wire, and it
465
482
  // can be refused above before it does. use() past the first attempt is a no-op.
466
483
  onDispatch?.();
@@ -499,12 +516,23 @@ export async function prepareAdapterExchange(
499
516
  if (options.abortSignal?.aborted) {
500
517
  return { failed: clientCancelledResponse() };
501
518
  }
519
+ // Same rule on the recovery leg: the ladder refused to send again, so the answer names
520
+ // this proxy rather than the provider it never reached.
521
+ if (err instanceof SendBudgetExhaustedError) {
522
+ return { failed: formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, err.message) };
523
+ }
502
524
  const msg = describeUpstreamConnectFailure(err, connectMs);
503
525
  return { failed: formatErrorResponse(502, "upstream_error", msg) };
504
526
  }
505
527
  };
506
528
  // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
507
529
  recovery: for (;;) {
530
+ // Preserve the terminal verdict through adapter and combo error formatting.
531
+ // This also covers a reset reached by a 401/429/413 recovery refetch.
532
+ if (isNonReplayableResponse(upstreamResponse)) {
533
+ cleanupUpstreamAbort();
534
+ return upstreamResponse;
535
+ }
508
536
  if (
509
537
  upstreamResponse.status === 401
510
538
  && isOAuth401ReplayProvider
@@ -593,6 +621,11 @@ export async function prepareAdapterExchange(
593
621
  const result = await rebuildAndRefetch("key-401");
594
622
  if ("failed" in result) return result.failed;
595
623
  upstreamResponse = result;
624
+ // A recovery refetch can itself die on an ambiguous pre-header reset, and the refusal
625
+ // that answers it is a 429. Every arm below keys on 429, so letting it fall through
626
+ // hands the marked refusal to the next waiting arm and replays the send it exists to
627
+ // stop. Re-enter the loop guard instead, which returns it unchanged.
628
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
596
629
  }
597
630
 
598
631
  // Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
@@ -633,6 +666,9 @@ export async function prepareAdapterExchange(
633
666
  const result = await rebuildAndRefetch("rate-limit-429");
634
667
  if ("failed" in result) return result.failed;
635
668
  upstreamResponse = result;
669
+ // The refusal is a 429 too: without this the while condition is still true and the
670
+ // next configured attempt replays it on the same target.
671
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
636
672
  }
637
673
 
638
674
  // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
@@ -664,6 +700,9 @@ export async function prepareAdapterExchange(
664
700
  const result = await rebuildAndRefetch("key-429");
665
701
  if ("failed" in result) return result.failed;
666
702
  upstreamResponse = result;
703
+ // Rotating on the refusal would also write a cooldown against a key that rate-limited
704
+ // nothing, which outlives the request.
705
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
667
706
  }
668
707
 
669
708
  // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
@@ -700,6 +739,7 @@ export async function prepareAdapterExchange(
700
739
  const result = await rebuildAndRefetch("anthropic-oauth-429");
701
740
  if ("failed" in result) return result.failed;
702
741
  upstreamResponse = result;
742
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
703
743
  } catch {
704
744
  break;
705
745
  }
@@ -722,9 +762,23 @@ export async function prepareAdapterExchange(
722
762
  // rebuildAndRefetch, so the roster cap alone would let one request walk the roster on
723
763
  // an allowance the rest of the request cannot see. A refusal ends the ladder with the
724
764
  // real 429 already in hand, which is the decided exhaustion contract.
765
+ //
766
+ // Who settles this reservation depends on who dispatches the replay (#4709). An
767
+ // adapter that owns its ladder -- Kiro's reset loop, Cursor's transport loop --
768
+ // reserves once per physical send and would charge the same replay again; the helper
769
+ // path reports it again through `onSendsConsumed`. Both turned one physical send into
770
+ // two charges, and once the allowance was spent, into a synthetic error in place of
771
+ // the 429 this hop was recovering from. The wire protocol is resolved from the
772
+ // provider and model, not from the account, so an account rotation cannot move the
773
+ // replay between these two shapes.
774
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
725
775
  const hop = reserveCredentialHop(
726
776
  "auth-recovery",
727
777
  `${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`,
778
+ // Only a helper-routed replay reports this send back. A reset-only refetch reports
779
+ // nothing and an adapter ladder settles the booking itself, so promising an external
780
+ // report on either would leave a booking pending until it swallowed a later charge.
781
+ !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
728
782
  );
729
783
  if (!hop.allowed) break;
730
784
  const nextAccountId = rotateGenericOAuthAccountOn429(
@@ -755,16 +809,33 @@ export async function prepareAdapterExchange(
755
809
  );
756
810
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
757
811
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
758
- // Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the
759
- // request and return `{ failed }` without reaching the wire, and a permit confirmed
760
- // before that would hold the charge for a send that never happened.
761
- const result = await rebuildAndRefetch("oauth-account-429", () => { hop.permit?.use(); });
812
+ // The replay IS this hop's send, so hand the reservation down and let the layer that
813
+ // dispatches settle it: `adapterDispatchBudget` spends it on the adapter's first
814
+ // reservation, and the retry helper's reporter settles the external booking.
815
+ sendBudgetState.pendingHopPermit = hop.permit;
816
+ let result: Response | { failed: Response };
817
+ try {
818
+ // Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the
819
+ // request and return `{ failed }` without reaching the wire, and a permit confirmed
820
+ // before that would hold the charge for a send that never happened. An
821
+ // adapter-owned ladder is the exception -- its own reservation is the confirmation,
822
+ // and settling here first would hand it a dead permit, which it reads as an
823
+ // exhausted request and stops sending on.
824
+ result = await rebuildAndRefetch("oauth-account-429", () => {
825
+ if (!adapterOwnsDispatch) hop.permit?.use();
826
+ });
827
+ } finally {
828
+ sendBudgetState.pendingHopPermit = undefined;
829
+ }
762
830
  if ("failed" in result) {
763
831
  // A no-op if the boundary was reached; a refund if the rebuild died before it.
764
832
  hop.permit?.release();
765
833
  return result.failed;
766
834
  }
767
835
  upstreamResponse = result;
836
+ // The hop's permit is already settled by the dispatch boundary above; continuing
837
+ // only skips the remaining arms, it does not abandon a reservation.
838
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
768
839
  } catch {
769
840
  // A throw before the send — snapshot fetch, credential application, adapter
770
841
  // resolution — must hand the reservation back. Without this the ladder charges the
@@ -1,3 +1,6 @@
1
+ import { mergeSteeringContinuation } from "./native-steering-settings";
2
+ import { markNativeControlResponse } from "./native-response-control";
3
+ import type { NativeResponseControl } from "./native-response-control";
1
4
  import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
2
5
  import { isSafeResponseHeader } from "../safe-response-headers";
3
6
  import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata";
@@ -6,10 +9,12 @@ import { CodexWsCorrelation } from "./codex-ws-correlation";
6
9
  import type { CodexWsSession } from "./codex-ws-session";
7
10
  import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
8
11
  MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
9
- codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
12
+ codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
10
13
  type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
11
14
 
12
15
  interface ExchangeOptions {
16
+ nativeControl?: NativeResponseControl;
17
+ beforeContinuation?: () => Promise<void>;
13
18
  session: CodexWsSession;
14
19
  url: string;
15
20
  init: RequestInit;
@@ -86,7 +91,7 @@ function wrappedRejectionResponse(payload: Record<string, unknown>, prelude: Hea
86
91
 
87
92
  /** The sole SSE exchange state machine for both one-shot and retained sockets. */
88
93
  export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
89
- const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch, bunVersion } = options;
94
+ const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch, bunVersion, nativeControl, beforeContinuation } = options;
90
95
  const { frameText, headers } = prepared;
91
96
  const signal = init.signal ?? undefined;
92
97
  return new Promise<Response>((resolve, reject) => {
@@ -116,6 +121,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
116
121
  const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
117
122
  const correlation = session.retainable ? new CodexWsCorrelation(session.reused, id => session.hasCompleted(id)) : null;
118
123
  let detachOwner = () => {};
124
+ let detachSteering = () => {};
125
+ let continuationBase: Record<string, unknown> | undefined;
119
126
  // Liveness while waiting for the first response event (metadata path only): the
120
127
  // silence timer is re-armed by every inbound frame or pong; the pinger runs on a fixed
121
128
  // interval so a peer that answers pings can never trip the silence bound while alive.
@@ -142,6 +149,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
142
149
  metadata?.finish();
143
150
  correlation?.finish();
144
151
  detachOwner();
152
+ detachSteering();
145
153
  ws.removeEventListener("open", onOpen);
146
154
  ws.removeEventListener("message", onMessage);
147
155
  ws.removeEventListener("close", onClose);
@@ -198,6 +206,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
198
206
  const response = new Response(stream, { status: 200, headers: responseHeaders });
199
207
  metadata?.commit();
200
208
  markCodexWsResponse(response, Boolean(metadata && onQuota));
209
+ if (nativeControl) markNativeControlResponse(response);
201
210
  markCodexWsStage(response, stageRecord(null));
202
211
  committedResponse = response;
203
212
  resolve(response);
@@ -329,6 +338,54 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
329
338
  }
330
339
  if (terminal || settledPreOpen || signal?.aborted) return;
331
340
  sent = true;
341
+ try {
342
+ if (nativeControl) {
343
+ // Parsed once: the base body is immutable for this exchange, and a
344
+ // full-replay frame runs to megabytes. It seeds continuationBase on
345
+ // the first create frame.
346
+ let base: Record<string, unknown> | undefined;
347
+ detachSteering = nativeControl.attach(frame => {
348
+ const sendControl = () => {
349
+ if (terminal || signal?.aborted || session.closed || ws.readyState !== WebSocket.OPEN) {
350
+ throw new Error("Native steering connection is no longer available");
351
+ }
352
+ beforeDispatch?.(new Headers(headers));
353
+ let outgoing = frame;
354
+ if (frame.type === "response.create") {
355
+ // Generation overrides have passed route policy; identity/tools remain pinned.
356
+ // Keep the last explicit wire settings for later explicit and automatic successors.
357
+ base ??= JSON.parse(frameText) as Record<string, unknown>;
358
+ continuationBase ??= base;
359
+ outgoing = nativeControl.kind === "steering"
360
+ ? mergeSteeringContinuation(continuationBase, frame)
361
+ : { ...continuationBase, input: frame.input, previous_response_id: frame.previous_response_id };
362
+ }
363
+ const text = JSON.stringify(outgoing);
364
+ if (codexWsCreateFrameExceedsLimit(text)) {
365
+ throw new Error("Native steering frame exceeds the transport byte limit");
366
+ }
367
+ if (frame.type === "response.create") continuationBase = outgoing;
368
+ try { ws.send(text); } catch {
369
+ // A send failure has unknown delivery. Never replay or fall back.
370
+ failStream("Native steering send failed; delivery is unknown");
371
+ throw new Error("Native steering send failed; delivery is unknown");
372
+ }
373
+ };
374
+ if (frame.type === "response.create" && beforeContinuation) {
375
+ // Explicit tool-result continuations are physical request starts;
376
+ // they keep provider pacing and revalidate auth AFTER the wait.
377
+ void beforeContinuation().then(sendControl).catch(() => failStream("Native steering continuation could not be dispatched; do not automatically replay queued input"));
378
+ } else sendControl();
379
+ }, error => failStream(error));
380
+ }
381
+ } catch (error) {
382
+ // An attach failure is an ownership conflict, not a failed send: no frame
383
+ // left the process, but the channel can never bind, so resolving the HTTP
384
+ // fallback here would silently degrade a multi-agent turn into an ordinary
385
+ // one. Fail the turn visibly instead.
386
+ failStream(error);
387
+ return;
388
+ }
332
389
  try {
333
390
  ws.send(frameText);
334
391
  sentAt = Date.now();
@@ -400,8 +457,12 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
400
457
  return;
401
458
  }
402
459
  if (!controlFrame && !type.startsWith("response.") && type !== "error") return;
460
+ let steeringEnded = false;
403
461
  if (!controlFrame) {
404
- try { correlation?.accept(normalized.payload); } catch (error) { failStream(error); return; }
462
+ try {
463
+ if (nativeControl) steeringEnded = nativeControl.observe(normalized.payload);
464
+ else correlation?.accept(normalized.payload);
465
+ } catch (error) { failStream(error); return; }
405
466
  // Correlation must run first: a reused socket's foreign-stream error settles as a
406
467
  // non-replayable 502 above, never as the refused-create 4xx projection below, which
407
468
  // is the one status family that could authorize an account replay.
@@ -443,7 +504,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
443
504
  return;
444
505
  }
445
506
  if (!controlFrame) relayedEvents += 1;
446
- if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
507
+ if (nativeControl ? steeringEnded : (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error")) {
447
508
  const completedId = correlation?.completed(normalized.payload) ?? null;
448
509
  terminal = true;
449
510
  cleanup();
@@ -108,6 +108,11 @@ export type CodexWsStageRecord = Omit<CodexWsFailureStage, "requestBytes"> & {
108
108
  const codexWsStageByResponse = new WeakMap<Response, CodexWsStageRecord>();
109
109
 
110
110
  export function markCodexWsStage(response: Response, record: CodexWsStageRecord): void {
111
+ const current = codexWsStageByResponse.get(response);
112
+ if (current) {
113
+ Object.assign(current, record);
114
+ return;
115
+ }
111
116
  codexWsStageByResponse.set(response, record);
112
117
  }
113
118
 
@@ -30,7 +30,7 @@ import {
30
30
  } from "../../combos";
31
31
  import { isInjectionDebugEnabled } from "../../lib/debug-settings";
32
32
  import { injectionDebugLog } from "../../lib/injection-debug-log";
33
- import { dottedToolName, modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types";
33
+ import { dottedToolName, modelInList, namespacedToolName, NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES, toolChoiceToolPredicate } from "../../types";
34
34
  import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
35
35
  import {
36
36
  forceRefreshOAuthAccessSnapshot,
@@ -83,7 +83,6 @@ import {
83
83
  catalogModelSupportsServiceTier,
84
84
  finishRequestAttempt,
85
85
  inspectResponseLogJson,
86
- noteAttemptSend,
87
86
  readConfiguredCodexServiceTier,
88
87
  requestLogSpeedLabel,
89
88
  sealRequestAttemptIdentity,
@@ -152,6 +151,36 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
152
151
  dottedAliasOwners.set(t.name, null);
153
152
  }
154
153
  }
154
+ // Bare echo alias (`name` with no namespace spelling, #4679): some providers — observed
155
+ // on the muse family via Command Code — echo a namespaced tool by its bare name. The
156
+ // bare spelling is only a safe alias while it names ONE tool and cannot be read as
157
+ // another identity's canonical or dotted spelling.
158
+ // Code-mode helper spellings never gain a bare alias (#4679 review), whatever namespace
159
+ // declares them and whatever put the alias there. The list is owned by `src/types/tools.ts`,
160
+ // beside the names it protects, because the copy that used to live here drifted to a single
161
+ // namespace and had to be widened twice.
162
+ const bareAliasOwners = new Map<string, string | null>();
163
+ for (const t of authorizedTools) {
164
+ // Bare (no-namespace) declarations participate as owners too: a namespaced tool whose
165
+ // bare name equals a bare-declared function must not gain the bare alias, mirroring how
166
+ // the tool_choice bare path refuses ambiguous owners across the whole request catalog.
167
+ const identity = JSON.stringify([t.namespace ?? null, t.name]);
168
+ const owner = bareAliasOwners.get(t.name);
169
+ if (owner === undefined) bareAliasOwners.set(t.name, identity);
170
+ else if (owner !== identity) bareAliasOwners.set(t.name, null);
171
+ }
172
+ for (const t of authorizedTools) {
173
+ const canonical = namespacedToolName(t.namespace, t.name);
174
+ const owner = bareAliasOwners.get(canonical);
175
+ if (owner !== undefined && owner !== JSON.stringify([t.namespace, t.name])) {
176
+ bareAliasOwners.set(canonical, null);
177
+ }
178
+ const dotted = dottedToolName(t.namespace, t.name);
179
+ const dottedOwner = bareAliasOwners.get(dotted);
180
+ if (dottedOwner !== undefined && dottedOwner !== JSON.stringify([t.namespace, t.name])) {
181
+ bareAliasOwners.set(dotted, null);
182
+ }
183
+ }
155
184
  for (const t of authorizedTools) {
156
185
  // Upstream output is untrusted: only restore calls for tools the caller authorized.
157
186
  const wireName = namespacedToolName(t.namespace, t.name);
@@ -175,6 +204,30 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
175
204
  toolNsMap.set(dottedName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
176
205
  if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(dottedName, t.parameters);
177
206
  }
207
+ // Bare echo alias (`name` with no namespace spelling, #4679): same tool identity as
208
+ // the flattened wire name, so a provider that drops the namespace prefix still
209
+ // restores against this entry. Ambiguous bare names were resolved to null above;
210
+ // skipping them falls back to the spellings every provider can still echo.
211
+ // Code-mode helper spellings never gain a bare alias, from ANY namespace (#4792 review).
212
+ // Scoping this to `collaboration` was too narrow to be a boundary: a declaration such as
213
+ // `mcp__remote.exec` donated bare `exec` to the declared set, and normalizeDeclaredToolName
214
+ // then rewrote an undeclared `apply_patch`, `exec_command` or `write_stdin` onto it
215
+ // (src/types/tools.ts). No namespace may turn helper normalization on for a catalog that
216
+ // never declared the code-mode shell.
217
+ //
218
+ // The namespaced tool stays usable AS ITSELF: `ns__name` is added unconditionally above
219
+ // and `ns.name` whenever it is unambiguous, so only the namespace-dropping echo fallback
220
+ // is withdrawn, and only for these six spellings.
221
+ if (
222
+ bareAliasOwners.get(t.name) === JSON.stringify([t.namespace, t.name])
223
+ && !NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)
224
+ ) {
225
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
226
+ declaredToolNames.add(t.name);
227
+ budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
228
+ toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
229
+ if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(t.name, t.parameters);
230
+ }
178
231
  }
179
232
  if (t.freeform) {
180
233
  budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
@@ -201,6 +254,20 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
201
254
  // Some routed providers echo a bare tool_choice selector instead of the flattened catalog
202
255
  // name. Accept only selectors the client actually sent and only when the full request catalog
203
256
  // contains one tool with that logical name.
257
+ //
258
+ // A helper spelling selected this way is split rather than refused (#4819). The two things a
259
+ // bare alias does are separable, and passthrough already relies on that: identity RESTORATION
260
+ // runs before authorization there, rewriting the echoed bare name to the namespaced identity
261
+ // the caller declared, and the guard then authorizes `ns__name`. DECLARATION is the part that
262
+ // is unsafe, because a declared-name set carrying bare `exec` is what makes
263
+ // `normalizeDeclaredToolName` rewrite an undeclared `apply_patch`, `exec_command` or
264
+ // `write_stdin` onto the selected tool (src/types/tools.ts).
265
+ //
266
+ // So a helper spelling gets the `toolNsMap` entry and not the `declaredToolNames` entry. The
267
+ // caller nominated exactly one tool by name, `bareNameCounts` proves nothing else answers to
268
+ // it, and restoring it authorizes nothing the request did not already declare. The echo path
269
+ // above withholds both, because a bare echo is a guess rather than a nomination and #4679
270
+ // pinned that shape (`tests/responses/bare-echo-alias.test.ts`).
204
271
  const choice = parsed.options.toolChoice;
205
272
  const bareChoiceNames = new Set(
206
273
  choice && typeof choice === "object"
@@ -213,8 +280,11 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
213
280
  }
214
281
  for (const t of authorizedTools) {
215
282
  if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue;
216
- budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
217
- declaredToolNames.add(t.name);
283
+ // Restore the identity; declare the name only when it is not a helper spelling.
284
+ if (!NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)) {
285
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
286
+ declaredToolNames.add(t.name);
287
+ }
218
288
  budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
219
289
  toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
220
290
  if (t.parameters && typeof t.parameters === "object") {
@@ -8,14 +8,46 @@ interface ComboRecallEntry {
8
8
  target: Pick<OcxComboTarget, "provider" | "model">;
9
9
  responseModel: string;
10
10
  at: number;
11
+ /** UTF-8 size of `responseModel`, the only client-influenced field of unbounded length. */
12
+ bytes: number;
11
13
  }
12
14
 
13
15
  const RECALL_CAPACITY = 256;
14
16
  const RECALL_TTL_MS = 30 * 60 * 1000;
17
+ /**
18
+ * A model id is provider-reported and arrives on the response, so nothing upstream of here
19
+ * bounds its length. Lane keys are already SHA-256 digests, so the model string is the only
20
+ * field that can grow, and 256 lanes alone do not bound the bytes they hold.
21
+ */
22
+ const RECALL_MODEL_BYTES_MAX = 1024;
23
+ const RECALL_TOTAL_BYTES_MAX = 64 * 1024;
15
24
  const recall = new Map<string, ComboRecallEntry>();
25
+ let recallBytes = 0;
16
26
  let lastReconciledGeneration = 0;
17
27
  let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined;
18
28
 
29
+ /** Every removal path goes through here so the byte counter can never drift from the map. */
30
+ function deleteEntry(lane: string): boolean {
31
+ const entry = recall.get(lane);
32
+ if (!entry) return false;
33
+ recall.delete(lane);
34
+ recallBytes -= entry.bytes;
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * UTF-8 size of a remembered model id, or null when it is too large to retain.
40
+ *
41
+ * The code-unit test runs first and is the part that matters: a UTF-8 encoding is never smaller
42
+ * than the code-unit count, so an oversized string is rejected without encoding it, and the
43
+ * bound cannot be defeated by paying the allocation it exists to prevent.
44
+ */
45
+ function boundedModelBytes(responseModel: string): number | null {
46
+ if (responseModel.length > RECALL_MODEL_BYTES_MAX) return null;
47
+ const bytes = Buffer.byteLength(responseModel, "utf8");
48
+ return bytes > RECALL_MODEL_BYTES_MAX ? null : bytes;
49
+ }
50
+
19
51
  function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean {
20
52
  return context.comboIds.has(entry.comboId)
21
53
  && context.providerNames.has(entry.target.provider)
@@ -32,14 +64,29 @@ export function rememberComboForLane(
32
64
  if (!lane || !comboId || !responseModel.trim()) return;
33
65
  // Reject even a same-named recreated owner: its previous in-flight turn is obsolete.
34
66
  if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return;
35
- const entry = { comboId, target: { provider: target.provider, model: target.model }, responseModel, at: Date.now() };
67
+ // An unretainable model id DECLINES the write; it must not clear the lane. Every other
68
+ // rejection above returns the same way, and clearing here would let a late completion erase
69
+ // a newer selection that this function has no ordering information to compare against.
70
+ const bytes = boundedModelBytes(responseModel);
71
+ if (bytes === null) return;
72
+ const entry = {
73
+ comboId,
74
+ target: { provider: target.provider, model: target.model },
75
+ responseModel,
76
+ at: Date.now(),
77
+ bytes,
78
+ };
36
79
  if (liveOwners && !ownsEntry(liveOwners, entry)) return;
37
- recall.delete(lane);
80
+ deleteEntry(lane);
38
81
  recall.set(lane, entry);
39
- while (recall.size > RECALL_CAPACITY) {
82
+ recallBytes += bytes;
83
+ // Insertion order is recency order, because every write re-inserts its lane at the back.
84
+ // Evicting from the front therefore drops the least recently written lane, never this one:
85
+ // a single entry is capped well below the aggregate budget, so it always fits.
86
+ while (recall.size > RECALL_CAPACITY || recallBytes > RECALL_TOTAL_BYTES_MAX) {
40
87
  const oldest = recall.keys().next().value;
41
- if (oldest === undefined) break;
42
- recall.delete(oldest);
88
+ if (oldest === undefined || oldest === lane) break;
89
+ deleteEntry(oldest);
43
90
  }
44
91
  }
45
92
 
@@ -57,12 +104,25 @@ export function recallComboForLane(
57
104
  || !Object.hasOwn(config.providers, entry.target.provider)
58
105
  || !provider || provider.disabled === true
59
106
  || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
60
- recall.delete(lane);
107
+ deleteEntry(lane);
61
108
  return undefined;
62
109
  }
63
110
  return entry.responseModel === model ? entry.comboId : undefined;
64
111
  }
65
112
 
113
+ /**
114
+ * Periodic expiry. Without it a lane that is never read again and never touched by a config
115
+ * reconciliation holds its entry for the life of the process: the existing TTL is only
116
+ * evaluated on read or on generation change.
117
+ */
118
+ export function sweepExpiredComboRecall(now: number): number {
119
+ let removed = 0;
120
+ for (const [lane, entry] of recall) {
121
+ if (now - entry.at >= RECALL_TTL_MS && deleteEntry(lane)) removed += 1;
122
+ }
123
+ return removed;
124
+ }
125
+
66
126
  export function reconcileComboRecall(context: GenerationContext): number {
67
127
  if (context.generation <= lastReconciledGeneration) return 0;
68
128
  lastReconciledGeneration = context.generation;
@@ -74,8 +134,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
74
134
  let removed = 0;
75
135
  for (const [lane, entry] of recall) {
76
136
  if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) {
77
- recall.delete(lane);
78
- removed += 1;
137
+ if (deleteEntry(lane)) removed += 1;
79
138
  }
80
139
  }
81
140
  return removed;
@@ -84,6 +143,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
84
143
  /** Test-only reset, alongside the combo rotation/cooldown resets. */
85
144
  export function clearComboRecallForTests(): void {
86
145
  recall.clear();
146
+ recallBytes = 0;
87
147
  lastReconciledGeneration = 0;
88
148
  liveOwners = undefined;
89
149
  }