@bitkyc08/opencodex 2.56.0 → 2.57.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 (145) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  8. package/src/adapters/command-code.ts +1 -1
  9. package/src/adapters/cursor/envelope-echo.ts +8 -2
  10. package/src/adapters/google.ts +7 -7
  11. package/src/adapters/kiro/payload.ts +17 -3
  12. package/src/adapters/kiro/reasoning.ts +70 -7
  13. package/src/adapters/kiro/stream.ts +8 -2
  14. package/src/adapters/kiro/wire.ts +2 -1
  15. package/src/adapters/kiro-events.ts +21 -13
  16. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  17. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  18. package/src/adapters/openai-chat.ts +8 -8
  19. package/src/adapters/openai-responses/passthrough.ts +32 -1
  20. package/src/bridge/errors.ts +26 -2
  21. package/src/bridge/response-json.ts +7 -1
  22. package/src/bridge/sse.ts +19 -1
  23. package/src/claude/desktop-profile.ts +66 -9
  24. package/src/claude/outbound.ts +18 -0
  25. package/src/cli/account-main.ts +1 -1
  26. package/src/cli/capabilities.ts +2 -2
  27. package/src/cli/combo.ts +10 -1
  28. package/src/cli/index.ts +48 -5
  29. package/src/cli/registry.ts +2 -1
  30. package/src/cli/system-command.ts +4 -4
  31. package/src/clients/config-export.ts +7 -3
  32. package/src/codex/account-label.ts +14 -3
  33. package/src/codex/account-store.ts +113 -26
  34. package/src/codex/account-usability.ts +21 -0
  35. package/src/codex/auth-api/login-flow.ts +14 -2
  36. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  37. package/src/codex/auth-context.ts +157 -7
  38. package/src/codex/catalog/aggregation.ts +80 -1
  39. package/src/codex/catalog/model-visibility.ts +1 -0
  40. package/src/codex/catalog/remote.ts +30 -0
  41. package/src/codex/catalog/retained-sync.ts +9 -1
  42. package/src/codex/catalog/routed-gather.ts +38 -1
  43. package/src/codex/cli-install-provenance.ts +7 -1
  44. package/src/codex/convergence.ts +7 -2
  45. package/src/codex/desktop-app/types.ts +11 -2
  46. package/src/codex/desktop-app/windows.ts +5 -5
  47. package/src/codex/inject/restore.ts +29 -2
  48. package/src/codex/inject.ts +9 -9
  49. package/src/codex/model-entitlements.ts +152 -15
  50. package/src/codex/pool-refresh-backoff.ts +12 -3
  51. package/src/codex/quota-rejection.ts +104 -15
  52. package/src/codex/routing/cache-affinity.ts +70 -0
  53. package/src/codex/routing/cooldown-math.ts +10 -0
  54. package/src/codex/routing/selection.ts +79 -2
  55. package/src/codex/routing/thread-affinity.ts +50 -2
  56. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  57. package/src/codex/routing.ts +29 -49
  58. package/src/codex/warmup.ts +1 -1
  59. package/src/combos/failover.ts +85 -0
  60. package/src/combos/request.ts +17 -10
  61. package/src/combos/types.ts +23 -2
  62. package/src/config/pending-teardown.ts +31 -0
  63. package/src/generated/compatibility-version.json +163 -135
  64. package/src/images/loop.ts +1 -1
  65. package/src/lib/errors.ts +17 -0
  66. package/src/lib/request-execution-budget.ts +147 -21
  67. package/src/lib/spend-reservation-ledger.ts +18 -0
  68. package/src/lib/state-store-registrations.ts +6 -2
  69. package/src/lib/test-home-guard.ts +85 -1
  70. package/src/lib/upstream-retry.ts +77 -10
  71. package/src/lib/windows-elevation.ts +76 -14
  72. package/src/oauth/index.ts +2 -2
  73. package/src/oauth/key-providers.ts +2 -2
  74. package/src/providers/kiro-models.ts +4 -3
  75. package/src/providers/label.ts +19 -1
  76. package/src/providers/model-discovery.ts +16 -0
  77. package/src/providers/registry/entries-core.ts +7 -0
  78. package/src/providers/registry/entries-extended.ts +9 -0
  79. package/src/providers/registry/model-seeds.ts +4 -0
  80. package/src/responses/reasoning-envelope.ts +6 -3
  81. package/src/routing/identity-domains.ts +21 -14
  82. package/src/routing/probe-lease.ts +103 -1
  83. package/src/server/chat-completions.ts +3 -1
  84. package/src/server/chat-native.ts +37 -9
  85. package/src/server/index/live-sideband.ts +37 -1
  86. package/src/server/index/websocket-handler.ts +6 -2
  87. package/src/server/index.ts +5 -5
  88. package/src/server/inspection-tee.ts +107 -0
  89. package/src/server/live.ts +46 -1
  90. package/src/server/management/combo-routes.ts +10 -1
  91. package/src/server/relay-eager.ts +2 -0
  92. package/src/server/relay.ts +14 -19
  93. package/src/server/request-log.ts +127 -3
  94. package/src/server/response-log-body.ts +153 -0
  95. package/src/server/responses/account-change-state.ts +74 -0
  96. package/src/server/responses/adapter-continuation.ts +33 -7
  97. package/src/server/responses/adapter-delivery.ts +5 -11
  98. package/src/server/responses/adapter-dispatch.ts +84 -13
  99. package/src/server/responses/codex-ws-wire.ts +5 -0
  100. package/src/server/responses/collaboration.ts +74 -4
  101. package/src/server/responses/combo-session-recall.ts +68 -8
  102. package/src/server/responses/compact.ts +54 -13
  103. package/src/server/responses/core-auth.ts +2 -0
  104. package/src/server/responses/core-codex-account.ts +51 -3
  105. package/src/server/responses/core-combo.ts +103 -23
  106. package/src/server/responses/core-errors.ts +18 -0
  107. package/src/server/responses/core-replay.ts +105 -32
  108. package/src/server/responses/core.ts +3 -3
  109. package/src/server/responses/encrypted-payload.ts +0 -1
  110. package/src/server/responses/input-admission.ts +126 -6
  111. package/src/server/responses/passthrough-delivery.ts +19 -6
  112. package/src/server/responses/passthrough-dispatch.ts +28 -10
  113. package/src/server/responses/passthrough-error.ts +38 -2
  114. package/src/server/responses/request-prepare.ts +132 -22
  115. package/src/server/responses/request-send-budget.ts +97 -2
  116. package/src/server/responses/request-spend.ts +147 -0
  117. package/src/server/responses/request-transport.ts +62 -3
  118. package/src/server/responses/run-turn-execution.ts +59 -31
  119. package/src/server/responses/sidecar-execution.ts +7 -13
  120. package/src/server/responses/terminal-guard.ts +65 -4
  121. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  122. package/src/service/windows-ops.ts +210 -16
  123. package/src/service/windows-scheduler.ts +28 -21
  124. package/src/service.ts +1 -1
  125. package/src/types/config.ts +4 -1
  126. package/src/types/request.ts +8 -5
  127. package/src/types/tools.ts +24 -0
  128. package/src/types.ts +2 -0
  129. package/src/update/index.ts +10 -0
  130. package/src/update/stop-contract.d.mts +1 -0
  131. package/src/update/stop-contract.mjs +19 -0
  132. package/src/update/stop-decision.d.mts +1 -1
  133. package/src/update/stop-decision.mjs +12 -3
  134. package/src/usage/log.ts +1 -1
  135. package/src/vision/anthropic-describe.ts +1 -1
  136. package/src/vision/describe.ts +5 -5
  137. package/src/web-search/anthropic-executor.ts +1 -1
  138. package/src/web-search/exa-executor.ts +1 -1
  139. package/src/web-search/executor.ts +1 -1
  140. package/src/web-search/gemini-executor.ts +1 -1
  141. package/src/web-search/loop.ts +1 -1
  142. package/src/web-search/ollama-executor.ts +1 -1
  143. package/src/web-search/parse.ts +67 -14
  144. package/src/web-search/passthrough-bridge.ts +64 -31
  145. package/src/web-search/xai-executor.ts +1 -1
@@ -12,14 +12,15 @@ import {
12
12
  adapterNeedsForcedContinuation,
13
13
  adapterResponseReachedServingTerminal,
14
14
  } from "./core-replay";
15
- import { sealRequestAttemptIdentity, noteAttemptSend, recordAttemptCredentialSource } from "../request-log";
15
+ import { sealRequestAttemptIdentity, recordAttemptCredentialSource } from "../request-log";
16
16
  import { waitForProviderRequestSlot, RequestPacingQueueOverloadError } from "../../providers/request-pacing";
17
17
  import type { AdapterEventQueue } from "../../adapters/run-turn-queue";
18
18
  import type { AttemptRecoveryKind } from "../../usage/log";
19
19
  import { providerFetch } from "./fetch-helpers";
20
20
  import { normalizeLogConversationId } from "../request-log-conversation";
21
21
  import type { AdapterEvent, OcxProviderContinuationState } from "../../types";
22
- import { adapterFailureFromMessage } from "../../lib/errors";
22
+ import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
23
+ import { SendBudgetExhaustedError } from "../../lib/upstream-retry";
23
24
  import {
24
25
  GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
25
26
  isGenericOAuthFailoverEnabled,
@@ -65,6 +66,8 @@ export async function executeResponsesRunTurn(
65
66
  | "applyFailoverSnapshot"
66
67
  | "resolveSelectionAdapter"
67
68
  | "adapter"
69
+ | "noteRoutedAttemptSend"
70
+ | "bindKeyUsageFromBridge"
68
71
  >,
69
72
  sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction">,
70
73
  responseEffects: Pick<
@@ -74,7 +77,10 @@ export async function executeResponsesRunTurn(
74
77
  | "continuationStateForResponse"
75
78
  | "notifyResponseComplete"
76
79
  >,
77
- sendBudgetState: Pick<ResponsesSendBudget, "adapterSendBudget" | "reserveCredentialHop">,
80
+ sendBudgetState: Pick<
81
+ ResponsesSendBudget,
82
+ "adapterDispatchBudget" | "reserveCredentialHop" | "pendingHopPermit"
83
+ >,
78
84
  completionPolicy: Pick<ResponsesCompletionPolicy, "emptyCompletionGuardEnabled">,
79
85
  ): Promise<Response> {
80
86
  const { options, logCtx, config } = requestContext;
@@ -94,7 +100,7 @@ export async function executeResponsesRunTurn(
94
100
  rememberKiroDeliveredFinalAnswer,
95
101
  responseStateOptions,
96
102
  } = requestState;
97
- const { adapterSendBudget, reserveCredentialHop } = sendBudgetState;
103
+ const { adapterDispatchBudget, reserveCredentialHop } = sendBudgetState;
98
104
  const { emptyCompletionGuardEnabled } = completionPolicy;
99
105
  const {
100
106
  cancelResponseCompletion,
@@ -140,7 +146,7 @@ export async function executeResponsesRunTurn(
140
146
  await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
141
147
  }
142
148
  await refreshRunTurnSelection();
143
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery);
149
+ transportState.noteRoutedAttemptSend(logCtx.usageLogInputTokens, recovery);
144
150
  const runTurnProviderFetch = providerFetch(
145
151
  route.provider,
146
152
  options.codexWsRuntimeIdentity,
@@ -162,7 +168,7 @@ export async function executeResponsesRunTurn(
162
168
  providerFetch: runTurnProviderFetch,
163
169
  // The only way the request budget reaches a transport the adapter owns. Without it
164
170
  // a Cursor turn's inner ladder was three physical sends the cap read as one.
165
- ...(adapterSendBudget ? { sendBudget: adapterSendBudget } : {}),
171
+ ...(adapterDispatchBudget ? { sendBudget: adapterDispatchBudget } : {}),
166
172
  },
167
173
  targetQueue.push,
168
174
  );
@@ -175,10 +181,22 @@ export async function executeResponsesRunTurn(
175
181
  retryable: true,
176
182
  message: err.message,
177
183
  }
178
- : {
179
- type: "error",
180
- message: err instanceof Error ? err.message : String(err),
181
- });
184
+ : err instanceof SendBudgetExhaustedError
185
+ // A structured terminal, not a bare message. The turn is already committed to an
186
+ // SSE response by the time most of these arrive, so the only way to carry "this
187
+ // proxy refused" to the client is on the event itself -- an unstructured message
188
+ // is inferred back to 502, which the Codex client retries.
189
+ ? {
190
+ type: "error",
191
+ status: 429,
192
+ errorType: "rate_limit_error",
193
+ code: SEND_BUDGET_EXHAUSTED_CODE,
194
+ message: err.message,
195
+ }
196
+ : {
197
+ type: "error",
198
+ message: err instanceof Error ? err.message : String(err),
199
+ });
182
200
  } finally {
183
201
  // Cursor assigns a stable conversation id inside runTurn on the first headerless
184
202
  // turn; backfill so Logs can filter/total that opening request (#330 / #522).
@@ -192,6 +210,11 @@ export async function executeResponsesRunTurn(
192
210
  const rotateRunTurnAdapterOnPreflight429 = async (
193
211
  error: Extract<AdapterEvent, { type: "error" }>,
194
212
  ): Promise<boolean> => {
213
+ // Our own refusal wears a 429 now, and rotating on it would record a cooldown against an
214
+ // account that never rate-limited anything -- a fake quota signal that outlives the
215
+ // request and misroutes later ones. The passthrough path has never had this problem
216
+ // because it answers before any rotation arm is reached.
217
+ if (error.code === SEND_BUDGET_EXHAUSTED_CODE) return false;
195
218
  const status = error.status ?? adapterFailureFromMessage(error.message).httpStatus;
196
219
  if (
197
220
  status !== 429
@@ -258,8 +281,11 @@ export async function executeResponsesRunTurn(
258
281
  });
259
282
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel);
260
283
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, rotatedAdapter.name);
261
- // The caller replays the turn on this rotation, so the reservation is now confirmed.
262
- hop.permit?.use();
284
+ // The caller replays the turn on this rotation, and a runTurn adapter dispatches through
285
+ // its own reservation ladder -- Cursor reserves once per physical send. Confirming here
286
+ // would leave that ladder to charge the same replay a second time (#4709), so hand the
287
+ // reservation down and let the send that actually happens spend it.
288
+ sendBudgetState.pendingHopPermit = hop.permit;
263
289
  return true;
264
290
  } catch {
265
291
  hop.permit?.release();
@@ -270,16 +296,24 @@ export async function executeResponsesRunTurn(
270
296
  firstSource: AsyncIterable<AdapterEvent>,
271
297
  ): Promise<AsyncIterable<AdapterEvent>> => {
272
298
  let source = firstSource;
273
- while (true) {
274
- const preflight = await preflightAdapterEvents(source);
275
- if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
276
- return preflight.stream;
299
+ try {
300
+ while (true) {
301
+ const preflight = await preflightAdapterEvents(source);
302
+ if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
303
+ return preflight.stream;
304
+ }
305
+ const retryQueue = createAdapterEventQueue({
306
+ onBacklogExceeded: () => runTurnAbort.abort(),
307
+ });
308
+ void runTurnAttempt(retryQueue, "oauth-account-429");
309
+ source = retryQueue.stream();
277
310
  }
278
- const retryQueue = createAdapterEventQueue({
279
- onBacklogExceeded: () => runTurnAbort.abort(),
280
- });
281
- void runTurnAttempt(retryQueue, "oauth-account-429");
282
- source = retryQueue.stream();
311
+ } finally {
312
+ // A handed-down hop reservation belongs to the replay this loop dispatched, and the
313
+ // loop only leaves after that replay's first event has arrived -- so the adapter has
314
+ // already reserved if it was ever going to. Dropping the reference here keeps an
315
+ // adapter that reserves nothing from leaving a free send for an unrelated later leg.
316
+ sendBudgetState.pendingHopPermit = undefined;
283
317
  }
284
318
  };
285
319
  // The empty-completion retry re-runs the turn against a fresh queue: the
@@ -340,6 +374,7 @@ export async function executeResponsesRunTurn(
340
374
  stallTimeoutSec: config.stallTimeoutSec,
341
375
  hideThinkingSummary: parsed.options.hideThinkingSummary,
342
376
  declaredToolNames,
377
+ enforceDeclaredToolNames: inboundWire !== "chat" && inboundWire !== "anthropic",
343
378
  toolParameterSchemas,
344
379
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
345
380
  ...(routedCompaction ? { compaction: true } : {}),
@@ -349,11 +384,7 @@ export async function executeResponsesRunTurn(
349
384
  onUsage: usage => {
350
385
  // Raw adapter usage, pre wire-normalization: the bridged SSE now always carries
351
386
  // zero-default detail objects, so provenance must come from here (cache_detail_missing).
352
- logCtx.usageFromBridge = true;
353
- if (usage) {
354
- logCtx.usage = usage;
355
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
356
- }
387
+ transportState.bindKeyUsageFromBridge(usage);
357
388
  },
358
389
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
359
390
  commitReasoningReplayServingRoute();
@@ -414,17 +445,14 @@ export async function executeResponsesRunTurn(
414
445
  hideThinkingSummary: parsed.options.hideThinkingSummary,
415
446
  toolNsMap,
416
447
  declaredToolNames,
448
+ enforceDeclaredToolNames: inboundWire !== "chat" && inboundWire !== "anthropic",
417
449
  toolParameterSchemas,
418
450
  freeformToolNames,
419
451
  toolSearchToolNames,
420
452
  ...(routedCompaction ? { compaction: true } : {}),
421
453
  onProviderState: state => { providerState = state; },
422
454
  onUsage: usage => {
423
- logCtx.usageFromBridge = true;
424
- if (usage) {
425
- logCtx.usage = usage;
426
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
427
- }
455
+ transportState.bindKeyUsageFromBridge(usage);
428
456
  },
429
457
  });
430
458
  if (!routedCompaction) {
@@ -35,7 +35,7 @@ import { bindRouteReasoningReplayScope, adapterNeedsForcedContinuation } from ".
35
35
  import { namespacedToolName } from "../../types";
36
36
  import { providerFetch } from "./fetch-helpers";
37
37
  import type { AttemptRecoveryKind } from "../../usage/log";
38
- import { noteAttemptSend, recordAdapterReasoning, recordAdapterTier } from "../request-log";
38
+ import { recordAdapterReasoning, recordAdapterTier } from "../request-log";
39
39
  import { normalizeLogConversationId } from "../request-log-conversation";
40
40
  import { rememberResponseState } from "../../responses/state";
41
41
  import { trackStreamLifetime } from "../lifecycle";
@@ -65,6 +65,8 @@ export async function executeResponsesSidecars(
65
65
  | "commitResolvedOAuthSelection"
66
66
  | "resolveSelectionAdapter"
67
67
  | "oauthDispatch"
68
+ | "noteRoutedAttemptSend"
69
+ | "bindKeyUsageFromBridge"
68
70
  >,
69
71
  sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction" | "openAiSidecar">,
70
72
  responseEffects: Pick<
@@ -322,7 +324,7 @@ export async function executeResponsesSidecars(
322
324
  ...(vidPlan ? { videoPlan: vidPlan } : {}),
323
325
  forwardHeaders: requestState.selectedForwardHeaders,
324
326
  onAttemptSend: (recovery?: AttemptRecoveryKind) =>
325
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
327
+ transportState.noteRoutedAttemptSend(logCtx.usageLogInputTokens, recovery),
326
328
  abortSignal: options.abortSignal,
327
329
  maxRounds: imgPlan && vidPlan
328
330
  ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
@@ -352,11 +354,7 @@ export async function executeResponsesSidecars(
352
354
  if (!logCtx.conversationId && parsed._cursorConversationId) {
353
355
  logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
354
356
  }
355
- logCtx.usageFromBridge = true;
356
- if (usage) {
357
- logCtx.usage = usage;
358
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
359
- }
357
+ transportState.bindKeyUsageFromBridge(usage);
360
358
  },
361
359
  on429: rotateSidecarProviderOn429,
362
360
  retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
@@ -432,13 +430,9 @@ export async function executeResponsesSidecars(
432
430
  recordAdapterTier(logCtx, request);
433
431
  },
434
432
  onAttemptSend: (recovery?: AttemptRecoveryKind) =>
435
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
433
+ transportState.noteRoutedAttemptSend(logCtx.usageLogInputTokens, recovery),
436
434
  onUsage: usage => {
437
- logCtx.usageFromBridge = true;
438
- if (usage) {
439
- logCtx.usage = usage;
440
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
441
- }
435
+ transportState.bindKeyUsageFromBridge(usage);
442
436
  },
443
437
  recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
444
438
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
@@ -13,6 +13,9 @@ const PLAN_OR_COMPLETION_RE = /(?:\b(?:i(?:'|’)m going to|i will|i(?:'|’)ll|
13
13
  const WAITING_FOR_USER_RE = /(?:[??]\s*$|需要我|请(?:确认|选择|提供)|是否|要不要|可以吗|\b(?:do you want|should i|which file|please confirm|please provide)\b)/iu;
14
14
  const EXPLICIT_CONTINUE_RE = /^(?:继续|接着|往下|go on|continue|proceed|keep going)\s*[.!。!]?$/iu;
15
15
  const MAX_ANNOUNCEMENT_CHARS = 280;
16
+ const MAX_RETAINED_EVENTS = 1_024;
17
+ // JavaScript string code units, not UTF-8 bytes or a process-wide memory limit.
18
+ const MAX_RETAINED_CONTENT_CHARS = 64 * 1_024;
16
19
 
17
20
  export const TERMINAL_GUARD_NUDGE =
18
21
  "你刚才只描述了计划,没有执行任何工具。不要再次解释计划,现在立即调用必要工具执行用户任务。" +
@@ -195,7 +198,17 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined):
195
198
  };
196
199
  }
197
200
 
198
- /** Preserve normal terminals, but withhold one suspicious no-tool terminal for a bounded re-ask. */
201
+ /**
202
+ * Forward adapter events and re-ask only short, suspicious no-tool completions.
203
+ * Retention is bounded per turn; tools or overflow disable analysis without truncating output.
204
+ * Reported usage from completed legs survives a continuation-factory failure. Unreported
205
+ * usage stays absent, and source-iteration failures propagate to the caller's transport handler.
206
+ *
207
+ * @param options Initial stream, parsed history, and continuation callback. The caller owns
208
+ * provider opt-in; the continuation limit defaults to one and is clamped to at most two.
209
+ * @yields Unchanged content events, internal assistant boundaries, and terminal events with
210
+ * accumulated reported usage when available. Returning the iterator closes its active source.
211
+ */
199
212
  export async function* guardTerminalEventStream(options: GuardedEventStreamOptions): AsyncGenerator<AdapterEvent> {
200
213
  const maxContinuations = Math.max(0, Math.min(2, Math.floor(options.maxAutoContinuations ?? 1)));
201
214
  let parsed = options.parsed;
@@ -205,6 +218,10 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
205
218
 
206
219
  while (true) {
207
220
  const seen: AdapterEvent[] = [];
221
+ let retainedContentChars = 0;
222
+ let retainedText = "";
223
+ let analysisEnabled = (options.adapterName === "anthropic" || options.adapterName === "openai-chat")
224
+ && continuations < maxContinuations;
208
225
  let terminalSeen = false;
209
226
  for await (const event of source) {
210
227
  // Liveness markers and tool argument fragments are passed through to the bridge, but
@@ -216,7 +233,7 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
216
233
  }
217
234
  if (event.type === "done") {
218
235
  terminalSeen = true;
219
- const analysis = (options.adapterName === "anthropic" || options.adapterName === "openai-chat")
236
+ const analysis = analysisEnabled
220
237
  ? analyzeTerminalTurn(parsed, seen)
221
238
  : { decision: "pass" as const };
222
239
  const normalStop = event.stopReason !== "max_tokens" && event.stopReason !== "content_filter";
@@ -224,11 +241,17 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
224
241
  accumulatedUsage = mergeUsage(accumulatedUsage, event.usage);
225
242
  continuations += 1;
226
243
  parsed = buildContinuationRequest(parsed, seen);
244
+ seen.length = 0;
245
+ retainedText = "";
227
246
  yield { type: "assistant_boundary" };
228
247
  try {
229
248
  source = await options.continuation(parsed);
230
249
  } catch (error) {
231
- yield { type: "error", message: error instanceof Error ? error.message : String(error) };
250
+ yield {
251
+ type: "error",
252
+ message: error instanceof Error ? error.message : String(error),
253
+ ...(accumulatedUsage ? { usage: accumulatedUsage } : {}),
254
+ };
232
255
  return;
233
256
  }
234
257
  break;
@@ -243,7 +266,45 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
243
266
  yield usage ? { ...event, usage } : event;
244
267
  return;
245
268
  }
246
- seen.push(event);
269
+ if (analysisEnabled) {
270
+ if (event.type === "tool_call_start") {
271
+ // A real tool call permanently rules out a no-tool continuation for this turn.
272
+ analysisEnabled = false;
273
+ } else if (
274
+ event.type === "text_delta"
275
+ || event.type === "thinking_delta"
276
+ || event.type === "thinking_signature"
277
+ || event.type === "redacted_thinking"
278
+ ) {
279
+ const content = event.type === "text_delta"
280
+ ? event.text
281
+ : event.type === "thinking_delta"
282
+ ? event.thinking
283
+ : event.type === "thinking_signature"
284
+ ? event.signature
285
+ : event.data;
286
+ if (
287
+ seen.length >= MAX_RETAINED_EVENTS
288
+ || content.length > MAX_RETAINED_CONTENT_CHARS - retainedContentChars
289
+ ) {
290
+ analysisEnabled = false;
291
+ } else {
292
+ retainedContentChars += content.length;
293
+ if (event.type === "text_delta") retainedText += content;
294
+ // Match analyzeTerminalTurn's trimmed-text semantics, including split padding.
295
+ if (retainedText.trim().length > MAX_ANNOUNCEMENT_CHARS) {
296
+ analysisEnabled = false;
297
+ } else {
298
+ seen.push(event);
299
+ }
300
+ }
301
+ }
302
+ if (!analysisEnabled) {
303
+ // Never rebuild a continuation from truncated thinking or a partial turn.
304
+ seen.length = 0;
305
+ retainedText = "";
306
+ }
307
+ }
247
308
  yield event;
248
309
  }
249
310
  if (!terminalSeen) return;
@@ -1,7 +1,7 @@
1
1
  import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, wireToolInnerName } from "../responses/tool-name-aliases";
2
2
  import {
3
- CODE_MODE_EXEC_TOOL_NAME,
4
3
  dottedToolName,
4
+ NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES,
5
5
  namespacedToolName,
6
6
  normalizeDeclaredToolName,
7
7
  } from "../types";
@@ -104,10 +104,14 @@ function addWireToolName(
104
104
  if (dottedAliasIsUnambiguous(namespace, name) && !ambiguousDottedAliases?.has(dotted)) {
105
105
  names.add(dotted);
106
106
  }
107
- // `exec` is the one name that also switches on nested-helper normalization, so a bare alias
108
- // for a namespaced MCP tool would silently authorize `exec_command`/`shell_command`/
109
- // `apply_patch`/`view_image` the request never declared. Every other inner name keeps the bare alias.
110
- if (name !== CODE_MODE_EXEC_TOOL_NAME) names.add(name);
107
+ // The code-mode helper spellings do not get a bare alias for a namespaced tool. Bare `exec`
108
+ // switches nested-helper normalization on for a catalog that never declared the shell; bare
109
+ // `exec_command`/`shell_command` switch it off for one that did; bare `write_stdin`/
110
+ // `apply_patch`/`view_image` are simply accepted as declared under a name the caller only ever
111
+ // authorized inside a namespace. This guard named only `exec` and let the other five through,
112
+ // which is the same drift the bridge-side copy had; both now read one list
113
+ // (src/types/tools.ts). Every other inner name keeps the bare alias.
114
+ if (!NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(name)) names.add(name);
111
115
  }
112
116
 
113
117
  /**
@@ -1,4 +1,5 @@
1
- import { chmodSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, lstatSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { win32 } from "node:path";
3
4
  import { winswXmlPath } from "../lib/winsw";
4
5
  import { hardenSecretPath } from "../lib/windows-secret-acl";
@@ -9,7 +10,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmdirSync, unlinkSync } from "node:
9
10
  import { tmpdir } from "node:os";
10
11
  import { dirname, join } from "node:path";
11
12
  import { getConfigDir } from "../config";
12
- import { runWindowsElevatedScheduledTaskRegistration, WindowsSchtasksError } from "../lib/windows-elevation";
13
+ import { OCX_ELEVATED_STAGING_UNREADABLE, runWindowsElevatedScheduledTaskRegistration, WindowsSchtasksError, type StagedWindowsTaskXml } from "../lib/windows-elevation";
13
14
  import { defaultWinswEntry, installWinswService, statusWinswRaw, uninstallWinswService, WINSW_SERVICE_ID, type WinswStatus } from "../lib/winsw";
14
15
  import { forgetEphemeralSecretDir, forgetEphemeralSecretPath, hardenSecretDir } from "../lib/windows-secret-acl";
15
16
  import { recordOwnedConfigPath } from "../lib/config-ownership";
@@ -165,6 +166,197 @@ function cleanupWindowsSchedulerStage(
165
166
  if (cleanupError) throw cleanupError;
166
167
  }
167
168
 
169
+ /** A staged payload set for one elevated registration, plus the way to remove it. */
170
+ export interface StagedElevatedSchedulerRegistration {
171
+ readonly xml: StagedWindowsTaskXml;
172
+ readonly expectedExisting?: StagedWindowsTaskXml;
173
+ /** Remove every staged artifact. Idempotent, so a second call after success is a no-op. */
174
+ cleanup(): void;
175
+ }
176
+
177
+ export interface ElevatedSchedulerStagingDeps {
178
+ createStageDir?: () => string;
179
+ hardenDir?: (path: string) => void;
180
+ writePayload?: (path: string, bytes: Buffer) => void;
181
+ hardenPath?: (path: string) => void;
182
+ inspect?: (path: string) => { isSymbolicLink(): boolean; isFile(): boolean; isDirectory(): boolean };
183
+ removeStageDir?: (path: string) => void;
184
+ }
185
+
186
+ /**
187
+ * Stage the captured definitions an elevated registration needs, as files rather than
188
+ * as command-line payloads (#4692).
189
+ *
190
+ * A file that an administrator process will read is itself a privilege-escalation
191
+ * surface, so three properties have to hold together and none of them is sufficient
192
+ * alone:
193
+ *
194
+ * - **Access.** The directory is created fresh by `mkdtemp`, then ACL-hardened before
195
+ * anything is written into it, so another local account cannot read or replace the
196
+ * payload while the UAC prompt is open. Hardening the directory first is what makes
197
+ * the file private from the moment it exists.
198
+ * - **No reparse point.** Each artifact is inspected with `lstat` and rejected unless it
199
+ * is what it claims to be. `wx` already refuses to create over an existing name, which
200
+ * is the atomic step here — there is no replace path to race, because every path is
201
+ * inside a directory that did not exist a moment ago. The explicit check is what keeps
202
+ * that guarantee from depending on a reading of `O_EXCL` semantics.
203
+ * - **Tamper evidence.** The digest is taken over the exact bytes written, and the
204
+ * elevated script recomputes it over the bytes it reads. An ACL cannot cover this:
205
+ * a process running as the same user has the same SID and can rewrite the file, so
206
+ * the digest is the only thing that makes such a swap fail closed rather than
207
+ * silently register a different task definition.
208
+ *
209
+ * Payloads are UTF-16LE with no BOM, and the elevated process decodes them straight into
210
+ * `Register-ScheduledTask`. What is hashed is therefore exactly what is registered, with
211
+ * no trimming step in between that the two sides could disagree about.
212
+ */
213
+ export function stageElevatedSchedulerRegistration(
214
+ xml: string,
215
+ expectedExistingXml?: string,
216
+ deps: ElevatedSchedulerStagingDeps = {},
217
+ ): StagedElevatedSchedulerRegistration {
218
+ const createStageDir = deps.createStageDir
219
+ ?? (() => mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX)));
220
+ const hardenDir = deps.hardenDir ?? ((path: string) => { hardenSecretDir(path, { required: true }); });
221
+ const writePayload = deps.writePayload ?? ((path: string, bytes: Buffer) => {
222
+ writeFileSync(path, bytes, { flag: "wx", mode: 0o600 });
223
+ });
224
+ const hardenPath = deps.hardenPath ?? ((path: string) => { hardenSecretPath(path, { required: true }); });
225
+ const inspect = deps.inspect ?? ((path: string) => lstatSync(path));
226
+ const removeStageDir = deps.removeStageDir ?? ((path: string) => { rmdirSync(path); });
227
+
228
+ const stageDir = createStageDir();
229
+ const files: string[] = [];
230
+ const cleanup = (): void => {
231
+ let failure: unknown;
232
+ for (const file of files.splice(0)) {
233
+ try {
234
+ unlinkSync(file);
235
+ forgetEphemeralSecretPath(file);
236
+ } catch (error) {
237
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") forgetEphemeralSecretPath(file);
238
+ else if (failure === undefined) failure = error;
239
+ }
240
+ }
241
+ try {
242
+ removeStageDir(stageDir);
243
+ forgetEphemeralSecretDir(stageDir);
244
+ } catch (error) {
245
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") forgetEphemeralSecretDir(stageDir);
246
+ else if (failure) throw new AggregateError([failure, error], "Elevated Task Scheduler staging cleanup failed.");
247
+ else failure = error;
248
+ }
249
+ if (failure) throw failure;
250
+ };
251
+
252
+ try {
253
+ try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ }
254
+ const dirStats = inspect(stageDir);
255
+ if (dirStats.isSymbolicLink() || !dirStats.isDirectory()) {
256
+ throw new Error(`Refusing to stage an elevated Task Scheduler payload under a redirected path: ${stageDir}`);
257
+ }
258
+ hardenDir(stageDir);
259
+ const stage = (name: string, value: string): StagedWindowsTaskXml => {
260
+ const path = join(stageDir, name);
261
+ const bytes = Buffer.from(value, "utf16le");
262
+ writePayload(path, bytes);
263
+ files.push(path);
264
+ const stats = inspect(path);
265
+ if (stats.isSymbolicLink() || !stats.isFile()) {
266
+ throw new Error(`Refusing to stage an elevated Task Scheduler payload through a redirected path: ${path}`);
267
+ }
268
+ hardenPath(path);
269
+ return { path, sha256: createHash("sha256").update(bytes).digest("hex") };
270
+ };
271
+ return {
272
+ xml: stage("register.xml", xml),
273
+ ...(expectedExistingXml === undefined
274
+ ? {}
275
+ : { expectedExisting: stage("expected.xml", expectedExistingXml) }),
276
+ cleanup,
277
+ };
278
+ } catch (error) {
279
+ try {
280
+ cleanup();
281
+ } catch (cleanupError) {
282
+ throw new AggregateError(
283
+ [error, cleanupError],
284
+ "Elevated Task Scheduler staging failed and could not be cleaned up.",
285
+ );
286
+ }
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Turn an elevated registration exit code into something an operator can act on.
293
+ *
294
+ * The elevated process runs hidden, so nothing it writes survives; only the exit code
295
+ * crosses back. That makes an unexplained code the whole user-facing error, which is
296
+ * exactly what made the ENAMETOOLONG in #4692 expensive to diagnose. Staging introduces
297
+ * one new failure of its own — the payload is readable only by the account that created
298
+ * it, so an elevation answered with a different administrator's credentials cannot open
299
+ * it — and that one gets named along with its remedy rather than surfacing as a number.
300
+ */
301
+ export function describeElevatedRegistrationFailure(
302
+ failureLabel: string,
303
+ exitCode: number,
304
+ stageDir: string,
305
+ ): string {
306
+ if (exitCode === OCX_ELEVATED_STAGING_UNREADABLE) {
307
+ return `${failureLabel}: the elevated process could not read the staged task definition in `
308
+ + `${stageDir}. That directory is readable only by the account that staged it, so this `
309
+ + "happens when the UAC prompt was answered with a different administrator account. "
310
+ + "Approve the prompt as the signed-in user, or run the command again from a session "
311
+ + "already elevated as that user.";
312
+ }
313
+ return `${failureLabel} with exit code ${exitCode}.`;
314
+ }
315
+
316
+ /**
317
+ * Stage, elevate, and clean up — on every exit, including UAC cancellation and a
318
+ * synchronous spawn failure.
319
+ *
320
+ * A cleanup failure never replaces the registration failure it followed: an operator
321
+ * told only that a temp directory could not be removed would have no idea the task was
322
+ * never registered.
323
+ */
324
+ async function runStagedElevatedSchedulerRegistration(
325
+ taskName: string,
326
+ xml: string,
327
+ replace: boolean,
328
+ expectedExistingXml: string | undefined,
329
+ failureLabel: string,
330
+ ): Promise<void> {
331
+ const staged = stageElevatedSchedulerRegistration(xml, expectedExistingXml);
332
+ let failure: unknown;
333
+ try {
334
+ const exitCode = await runWindowsElevatedScheduledTaskRegistration(
335
+ taskName,
336
+ staged.xml,
337
+ replace,
338
+ staged.expectedExisting,
339
+ );
340
+ if (exitCode !== 0) {
341
+ failure = new Error(describeElevatedRegistrationFailure(failureLabel, exitCode, dirname(staged.xml.path)));
342
+ }
343
+ } catch (error) {
344
+ failure = error;
345
+ }
346
+ try {
347
+ staged.cleanup();
348
+ } catch (cleanupError) {
349
+ if (failure) {
350
+ throw new AggregateError(
351
+ [failure, cleanupError],
352
+ "Elevated Task Scheduler registration failed and its staging could not be cleaned up.",
353
+ );
354
+ }
355
+ throw cleanupError;
356
+ }
357
+ if (failure) throw failure;
358
+ }
359
+
168
360
  export function stageWindowsSchedulerRegistrationXml(
169
361
  attemptNonce: string,
170
362
  deps: WindowsSchedulerRegistrationStageDeps = {},
@@ -294,7 +486,8 @@ export async function registerFreshWindowsSchedulerTask(
294
486
  throw error;
295
487
  }
296
488
  // Register from the captured XML string inside the elevated process. Another
297
- // same-user process can mutate its own temp files, but cannot change this command.
489
+ // same-user process can mutate its own temp files, so the captured bytes are staged
490
+ // privately and the elevated script verifies their digest before registering them.
298
491
  // UAC can remain open for an arbitrary amount of time. Recheck the captured predecessor
299
492
  // before launch; the elevated helper repeats the same check after consent and before Force.
300
493
  assertReplacementPrecondition();
@@ -303,15 +496,13 @@ export async function registerFreshWindowsSchedulerTask(
303
496
  xml: string,
304
497
  replaceCurrent: boolean,
305
498
  previousXml?: string,
306
- ) => {
307
- const exitCode = await runWindowsElevatedScheduledTaskRegistration(
308
- taskName,
309
- xml,
310
- replaceCurrent,
311
- previousXml,
312
- );
313
- if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`);
314
- });
499
+ ) => runStagedElevatedSchedulerRegistration(
500
+ taskName,
501
+ xml,
502
+ replaceCurrent,
503
+ previousXml,
504
+ "Background service install failed",
505
+ ));
315
506
  await elevate(TASK, expectedXml, replace, expectedExistingXml);
316
507
  }
317
508
 
@@ -501,10 +692,13 @@ export async function restoreWindowsSchedulerTaskIfAbsent(registeredXml: string)
501
692
  ) {
502
693
  throw error;
503
694
  }
504
- const exitCode = await runWindowsElevatedScheduledTaskRegistration(TASK, registeredXml, false);
505
- if (exitCode !== 0) {
506
- throw new Error(`Task Scheduler rollback failed with exit code ${exitCode}.`);
507
- }
695
+ await runStagedElevatedSchedulerRegistration(
696
+ TASK,
697
+ registeredXml,
698
+ false,
699
+ undefined,
700
+ "Task Scheduler rollback failed",
701
+ );
508
702
  }
509
703
  const recoveredXml = statusWindowsXml();
510
704
  if (!windowsSchedulerRegistrationMatchesSnapshot(recoveredXml, registeredXml)) {