@yansigit/opencodex 2.33.1 → 2.35.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 (177) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-BjCaHxdz.js +112 -0
  3. package/gui/dist/assets/index-DLkXOXLC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -3
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +40 -3
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +9 -9
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +14 -0
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-shell.ts +103 -6
  17. package/src/adapters/cursor/native-exec.ts +20 -0
  18. package/src/adapters/cursor/protobuf-request.ts +74 -39
  19. package/src/adapters/cursor/request-builder.ts +32 -10
  20. package/src/adapters/cursor/tool-definitions.ts +13 -10
  21. package/src/adapters/cursor/tool-result-normalize.ts +35 -3
  22. package/src/adapters/cursor/types.ts +16 -4
  23. package/src/adapters/cursor.ts +97 -70
  24. package/src/adapters/google-antigravity-replay.ts +105 -25
  25. package/src/adapters/google-antigravity-wire.ts +5 -0
  26. package/src/adapters/google-errors.ts +41 -12
  27. package/src/adapters/google-http.ts +12 -11
  28. package/src/adapters/google.ts +156 -65
  29. package/src/adapters/image.ts +1 -1
  30. package/src/adapters/kiro-constants.ts +15 -0
  31. package/src/adapters/kiro-tools.ts +43 -15
  32. package/src/adapters/kiro.ts +54 -9
  33. package/src/adapters/openai-chat.ts +286 -242
  34. package/src/adapters/openai-responses.ts +334 -24
  35. package/src/adapters/run-turn-queue.ts +36 -1
  36. package/src/adapters/tool-catalog-nudge.ts +2 -2
  37. package/src/adapters/xai-tool-schema.ts +436 -0
  38. package/src/bridge.ts +67 -26
  39. package/src/chat/inbound.ts +14 -1
  40. package/src/chat/outbound.ts +15 -7
  41. package/src/claude/agents-inject.ts +8 -1
  42. package/src/claude/outbound.ts +10 -8
  43. package/src/cli/account-api.ts +27 -7
  44. package/src/cli/account-extended.ts +10 -3
  45. package/src/cli/account.ts +29 -5
  46. package/src/cli/alias.ts +66 -0
  47. package/src/cli/claude.ts +26 -1
  48. package/src/cli/dispatch.ts +13 -1
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli/index.ts +5 -0
  51. package/src/cli/init.ts +1 -0
  52. package/src/cli/models-runtime.ts +95 -0
  53. package/src/cli/models.ts +13 -7
  54. package/src/cli/provider-runtime.ts +16 -2
  55. package/src/cli/registry.ts +6 -1
  56. package/src/cli/telemetry-commands.ts +25 -0
  57. package/src/cli/v2.ts +34 -10
  58. package/src/codex/account-pause.ts +2 -1
  59. package/src/codex/account-priority.ts +3 -2
  60. package/src/codex/app-server-processes.ts +80 -6
  61. package/src/codex/auth-api.ts +48 -8
  62. package/src/codex/auth-context.ts +21 -18
  63. package/src/codex/catalog/aggregation.ts +6 -0
  64. package/src/codex/catalog/model-metadata.ts +13 -1
  65. package/src/codex/catalog/native-models.ts +5 -2
  66. package/src/codex/catalog/parsing.ts +16 -0
  67. package/src/codex/catalog/provider-fetch.ts +20 -37
  68. package/src/codex/catalog/sync.ts +127 -2
  69. package/src/codex/catalog.ts +1 -1
  70. package/src/codex/codex-write-lock.ts +3 -1
  71. package/src/codex/convergence-types.ts +1 -1
  72. package/src/codex/convergence.ts +22 -2
  73. package/src/codex/desired-state.ts +2 -2
  74. package/src/codex/desktop-app-restart.ts +18 -5
  75. package/src/codex/inject-coordination.ts +83 -0
  76. package/src/codex/inject.ts +14 -1
  77. package/src/codex/log-guard/inspect.ts +22 -4
  78. package/src/codex/model-entitlements.ts +9 -2
  79. package/src/codex/prompt-layers.ts +371 -25
  80. package/src/codex/prompt-text-probe.ts +238 -0
  81. package/src/codex/quota.ts +123 -18
  82. package/src/codex/routing.ts +9 -0
  83. package/src/codex/subagent-model-fallback.ts +198 -27
  84. package/src/codex/transition-state.ts +107 -8
  85. package/src/combos/types.ts +10 -0
  86. package/src/compatibility/openai-responses.ts +33 -1
  87. package/src/config/autonomous-remediation.ts +21 -0
  88. package/src/config/provider-validation.ts +14 -0
  89. package/src/config/rebase-provenance.ts +68 -0
  90. package/src/config.ts +191 -17
  91. package/src/generated/compatibility-version.json +229 -153
  92. package/src/images/loop.ts +5 -4
  93. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  94. package/src/lab/fabric/producer-child.ts +1 -1
  95. package/src/lib/config-ownership.ts +20 -0
  96. package/src/lib/errors.ts +11 -2
  97. package/src/lib/package-tree-integrity.ts +101 -0
  98. package/src/oauth/aistudio-credentials.ts +65 -0
  99. package/src/oauth/aistudio-native-daemon.ts +54 -0
  100. package/src/oauth/generic-account-failover.ts +231 -0
  101. package/src/oauth/index.ts +57 -5
  102. package/src/oauth/key-providers.ts +10 -1
  103. package/src/oauth/kiro.ts +45 -0
  104. package/src/oauth/login-cli.ts +19 -20
  105. package/src/oauth/types.ts +15 -0
  106. package/src/providers/codex-capacity.ts +5 -2
  107. package/src/providers/command-code-efforts.ts +38 -6
  108. package/src/providers/context-cap.ts +4 -3
  109. package/src/providers/default-aliases.ts +65 -0
  110. package/src/providers/derive.ts +28 -0
  111. package/src/providers/fastwire.ts +7 -1
  112. package/src/providers/model-presets.ts +119 -0
  113. package/src/providers/new-model-policy.ts +146 -0
  114. package/src/providers/provider-id-rewrite.ts +2 -1
  115. package/src/providers/quota.ts +73 -14
  116. package/src/providers/registry.ts +160 -68
  117. package/src/providers/slug-codec.ts +52 -0
  118. package/src/responses/code-mode-helper-compat.ts +50 -0
  119. package/src/responses/custom-tool-compat.ts +34 -10
  120. package/src/responses/parser.ts +4 -0
  121. package/src/responses/schema.ts +5 -1
  122. package/src/responses/thought-signature-replay.ts +17 -0
  123. package/src/router.ts +40 -2
  124. package/src/routing/analytics.ts +1 -0
  125. package/src/routing/quota.ts +10 -0
  126. package/src/server/auth-cors.ts +23 -0
  127. package/src/server/chat-completions.ts +24 -16
  128. package/src/server/chat-native-sse.ts +3 -3
  129. package/src/server/chat-native.ts +30 -11
  130. package/src/server/claude-messages.ts +1 -1
  131. package/src/server/effort-policy.ts +16 -0
  132. package/src/server/index.ts +152 -80
  133. package/src/server/lifecycle.ts +52 -1
  134. package/src/server/management/agent-settings-routes.ts +31 -15
  135. package/src/server/management/codex-prompt-routes.ts +570 -0
  136. package/src/server/management/combo-routes.ts +2 -1
  137. package/src/server/management/config-routes.ts +27 -9
  138. package/src/server/management/context.ts +9 -0
  139. package/src/server/management/logs-usage-routes.ts +1 -1
  140. package/src/server/management/model-routes.ts +266 -0
  141. package/src/server/management/provider-routes.ts +137 -3
  142. package/src/server/management/routing-profile-routes.ts +2 -2
  143. package/src/server/management-api.ts +2 -0
  144. package/src/server/relay-eager.ts +147 -20
  145. package/src/server/relay.ts +251 -19
  146. package/src/server/request-log-conversation.ts +21 -0
  147. package/src/server/request-log.ts +46 -20
  148. package/src/server/responses/collaboration.ts +42 -5
  149. package/src/server/responses/combo-stream-preflight.ts +10 -3
  150. package/src/server/responses/core.ts +571 -137
  151. package/src/server/responses/empty-completion-guard.ts +35 -0
  152. package/src/server/responses/fetch-helpers.ts +14 -6
  153. package/src/server/responses/input-admission.ts +3 -1
  154. package/src/server/responses/passthrough-error.ts +33 -9
  155. package/src/server/responses/responses-field-backfill.ts +105 -13
  156. package/src/server/responses/ws-upstream.ts +35 -5
  157. package/src/server/responses-custom-tool-repair.ts +52 -7
  158. package/src/server/responses-terminal-repair.ts +25 -4
  159. package/src/server/sse-frame-buffer.ts +31 -4
  160. package/src/server/ws-bridge.ts +15 -4
  161. package/src/telemetry/dispatcher.ts +44 -0
  162. package/src/telemetry/fingerprint.ts +24 -0
  163. package/src/telemetry/hook.ts +43 -0
  164. package/src/telemetry/ledger.ts +54 -0
  165. package/src/telemetry/types.ts +23 -0
  166. package/src/types/config.ts +66 -14
  167. package/src/types/provider.ts +77 -0
  168. package/src/types/request.ts +16 -10
  169. package/src/types/tools.ts +20 -25
  170. package/src/types.ts +1 -0
  171. package/src/usage/expected-prices.ts +43 -24
  172. package/src/usage/log.ts +17 -7
  173. package/src/web-search/exa-executor.ts +40 -9
  174. package/src/web-search/loop.ts +5 -4
  175. package/gui/dist/assets/index-CIDo4y4k.js +0 -102
  176. package/gui/dist/assets/index-DrSQdTRd.css +0 -1
  177. package/src/server/aistudio-ws-hub.ts +0 -295
@@ -29,6 +29,31 @@ function isNodeReplOrComputerUseTool(toolName?: string, toolNamespace?: string):
29
29
  return lower.startsWith("mcp__node_repl") || lower.startsWith("mcp__computer_use");
30
30
  }
31
31
 
32
+ /**
33
+ * Codex exec / shell-bridge tool names (flat and MCP-prefixed display aliases). An empty result
34
+ * here is almost always a code-mode cell that never called text()/notify() — the cursor model
35
+ * reads the blank [tool_result], concludes prior results were lost, and spirals into
36
+ * re-orientation retries (devlog 260826_cursor_responses_gap, live subagent transcripts).
37
+ */
38
+ function isCodexExecBridgeTool(toolName?: string, toolNamespace?: string): boolean {
39
+ if (toolNamespace && toolNamespace.includes("opencodex-responses")) return true;
40
+ if (!toolName) return false;
41
+ const lower = toolName.toLowerCase();
42
+ return (
43
+ lower === "exec"
44
+ || lower === "exec_command"
45
+ || lower === "shell_command"
46
+ // Codex CLI/desktop native tool names: the multi-round "이전 출력이 비어 있어 처음부터"
47
+ // restart loop reproduced via codex exec because `shell` was not in this set
48
+ // (devlog 260826 gap-8 QA round 2).
49
+ || lower === "shell"
50
+ || lower === "local_shell"
51
+ || lower === "container.exec"
52
+ || lower.startsWith("mcp_opencodex-responses_")
53
+ || lower.startsWith("mcp__opencodex-responses__")
54
+ );
55
+ }
56
+
32
57
  /** Failure states the Computer Use / node_repl runtime reports as PLAIN TEXT inside a non-error result. */
33
58
  const RUNTIME_FAILURE_GUIDANCE: ReadonlyArray<{ marker: string; guidance: string }> = [
34
59
  {
@@ -44,9 +69,9 @@ const RUNTIME_FAILURE_GUIDANCE: ReadonlyArray<{ marker: string; guidance: string
44
69
  guidance: "The node_repl session keeps earlier declarations; rename the variable or use var/reassignment instead of redeclaring.",
45
70
  },
46
71
  {
47
- marker: "unsupported import in exec",
48
- guidance: "Imports are not available in this exec context; use the injected globals instead.",
49
- },
72
+ marker: "unsupported import in exec",
73
+ guidance: "Imports are not available in this exec context; use the injected globals instead.",
74
+ },
50
75
  {
51
76
  marker: "require is not defined",
52
77
  guidance: "In Codex code-mode exec (a V8 isolate, not Node.js), require/fs/process are not available. Use await tools.exec_command({ cmd: '...' }) inside exec to inspect files or run CLI tools.",
@@ -96,6 +121,13 @@ export function normalizeCursorToolResultText(
96
121
  changed: true,
97
122
  };
98
123
  }
124
+ if (isCodexExecBridgeTool(options.toolName, options.toolNamespace) && EMPTY_EXEC_OUTPUT_REGEX.test(text.trim())) {
125
+ return {
126
+ text: "[empty output: the exec cell completed but emitted nothing. This is NOT lost context and NOT a blocked tool — in code mode call text(...) or notify(...) on any value you need to see (a bare await tools.exec_command(...) is not echoed automatically); in shell mode the command simply printed nothing. Do not re-run the same call expecting different output.]",
127
+ isError: false,
128
+ changed: true,
129
+ };
130
+ }
99
131
  for (const { marker, guidance } of RUNTIME_FAILURE_GUIDANCE) {
100
132
  if (text.includes(marker) && !text.includes("[recovery:") && !text.includes(guidance)) {
101
133
  return { text: `${text}\n[recovery: ${guidance}]`, isError: true, changed: true };
@@ -15,6 +15,19 @@ export interface CursorRunRequest {
15
15
  requestedModelParameters?: readonly CursorRequestedModelParameter[];
16
16
  /** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */
17
17
  routingLevel?: CursorRoutingLevel;
18
+ /**
19
+ * Cursor Max Mode (ultra/big-context). Set from a synthetic `-1m` picker variant; the wire
20
+ * keeps the original model id and raises RequestedModel.maxMode + ModelDetails.maxMode
21
+ * (both fields — missing either can invalid_argument upstream). Devlog 260826 070.
22
+ */
23
+ maxMode?: boolean;
24
+ /**
25
+ * Bare API callers (no caller tools, no Codex thread identity) pay a ~10-15K input-token
26
+ * preamble because an absent AgentRunRequest.mcp_tools field makes Cursor inject its default
27
+ * native tool catalog. When true, an explicitly empty McpTools wrapper is serialized instead,
28
+ * suppressing that default. Codex-identified sessions keep the absent-field behavior.
29
+ */
30
+ suppressDefaultCursorToolCatalog?: boolean;
18
31
  /**
19
32
  * Corrective active-turn text for the single envelope-echo retry (devlog 260826 gap-10).
20
33
  * When set on an external tool-result continuation, buildPreparedCursorRunRequest uses it as
@@ -32,10 +45,9 @@ export interface CursorRunRequest {
32
45
  * hydration). History stays text-only. data: URLs only in this slice.
33
46
  */
34
47
  selectedImages?: readonly ResolvedCursorImage[];
35
- tools?: OcxTool[];
36
- toolChoice?: OcxRequestOptions["toolChoice"];
37
- textFormat?: OcxRequestOptions["textFormat"];
38
- parallelToolCalls?: boolean;
48
+ tools?: OcxTool[];
49
+ toolChoice?: OcxRequestOptions["toolChoice"];
50
+ parallelToolCalls?: boolean;
39
51
  /**
40
52
  * Clear provider-private context-usage carry-forward before this run. Used when Codex starts a
41
53
  * newly observed compacted context epoch, so pre-compaction totals are not over-reported while
@@ -3,18 +3,13 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types
3
3
  import type { ProviderAdapter } from "./base";
4
4
  import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
5
5
  import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
6
- import {
7
- isCursorBenignCancelError,
8
- isCursorInvalidArgumentError,
9
- isCursorOverflowRemintCandidate,
10
- safeCursorErrorMessage,
11
- type CursorSizeContext,
12
- } from "./cursor/cursor-errors";
6
+ import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
13
7
  import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
14
8
  import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
15
9
  import { mapCursorServerMessage } from "./cursor/message-mapper";
16
10
  import {
17
11
  createCursorRequest,
12
+ cursorClientThreadOwner,
18
13
  cursorCoveredPrefixDigest,
19
14
  cursorInstructionDigest,
20
15
  } from "./cursor/request-builder";
@@ -176,15 +171,36 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
176
171
  let completedNormally = false;
177
172
  let lastTransport: { captured?: Uint8Array } | undefined;
178
173
  let emittedClientTool = false;
174
+ // Ordering proof for tool-suspended checkpoints: true only when the newest captured
175
+ // checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream
176
+ // serialized its suspended-on-tool-call state. Only that snapshot can safely resume
177
+ // with the covered-prefix + trailing-toolResult path (devlog 260826 050).
178
+ let capturedAfterClientTool = false;
179
179
 
180
180
  const commitCapturedCheckpoint = (activeRequest: ReturnType<typeof createCursorRequest>): void => {
181
+ const toolSuspendedCommit =
182
+ emittedClientTool
183
+ && capturedAfterClientTool
184
+ && isCursorExternalWireModel(activeRequest.modelId);
181
185
  if (
182
186
  replayUnsafe
183
- || emittedClientTool
187
+ || (emittedClientTool && !toolSuspendedCommit)
184
188
  || activeRequest.contextUsageStoreCheckpoints === false
185
189
  || !lastTransport?.captured
186
190
  || lastTransport.captured.byteLength === 0
187
- ) return;
191
+ ) {
192
+ // Refusal diagnostics (devlog 260826 050/080): name the exact guard so a live
193
+ // missing_ref chain can be attributed without instrumented rebuilds.
194
+ debugProviderDiagnostic("cursor", "checkpoint-commit-refused", {
195
+ replayUnsafe,
196
+ emittedClientTool,
197
+ capturedAfterClientTool,
198
+ externalModel: isCursorExternalWireModel(activeRequest.modelId),
199
+ storeCheckpoints: activeRequest.contextUsageStoreCheckpoints !== false,
200
+ capturedBytes: lastTransport?.captured?.byteLength ?? 0,
201
+ });
202
+ return;
203
+ }
188
204
  const previousRef = _parsed._providerContinuation?.cursor?.checkpointRef;
189
205
  const coveredMessageCount = _parsed.context.messages.length;
190
206
  const checkpointRef = commitCursorCheckpoint({
@@ -203,7 +219,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
203
219
  cursor: {
204
220
  ...(_parsed._providerContinuation?.cursor ?? {}),
205
221
  conversationId: activeRequest.conversationId,
206
- checkpointUsable: true,
222
+ // A tool-suspended checkpoint is only usable by the immediate trailing-toolResult
223
+ // continuation; the request-builder guard keys on checkpointUsable=false for that.
224
+ checkpointUsable: !toolSuspendedCommit,
207
225
  checkpointRef,
208
226
  },
209
227
  };
@@ -213,6 +231,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
213
231
  checkpointRefHash: cursorCheckpointRefHash(checkpointRef),
214
232
  checkpointBytes: lastTransport.captured.byteLength,
215
233
  wireModel: activeRequest.modelId,
234
+ ...(toolSuspendedCommit ? { toolSuspended: true } : {}),
216
235
  });
217
236
  };
218
237
 
@@ -225,6 +244,14 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
225
244
  || activeRequest.modelId.includes("kimi-k3")
226
245
  || activeRequest.modelId.includes("opus-4-8");
227
246
  const heartbeatOnlyMs = isHeavyReasoning ? 300_000 : 180_000;
247
+ // Envelope echo quarantine (devlog 260826 gap-10): external full-replay continuations
248
+ // whose trailing input is a tool result sometimes ECHO the replayed "[Tool Result]"
249
+ // envelope as assistant text (kimi-k3 ~30-40% of multi-round probes). Hold the first
250
+ // text deltas until they provably diverge from the markers; a completed marker turns
251
+ // the turn into a retryable semantic failure BEFORE any client-visible delta escapes.
252
+ // Armed for ANY external turn whose replayed history contains a tool result — echo
253
+ // priming was observed live on user-action rounds too (the envelope lives in the
254
+ // flattened history regardless of which role ends the input).
228
255
  const armEchoSniffer =
229
256
  isCursorExternalWireModel(activeRequest.modelId)
230
257
  && (_parsed.context.messages ?? []).some(message => message.role === "toolResult");
@@ -271,14 +298,17 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
271
298
  if (message.type === "done") completedNormally = true;
272
299
  if (message.type === "tool_call_end") emittedClientTool = true;
273
300
  const captured = capturedCursorCheckpointBytes(activeTransport);
274
- if (captured) lastTransport = { captured };
275
- const events = mapCursorServerMessage(message, {
276
- kv,
277
- writeClient: clientMessage => {
278
- void activeTransport.writeClient(clientMessage);
279
- },
280
- });
281
- for (const event of events) {
301
+ if (captured) {
302
+ if (captured !== lastTransport?.captured) capturedAfterClientTool = emittedClientTool;
303
+ lastTransport = { captured };
304
+ }
305
+ const events = mapCursorServerMessage(message, {
306
+ kv,
307
+ writeClient: clientMessage => {
308
+ void activeTransport.writeClient(clientMessage);
309
+ },
310
+ });
311
+ for (const event of events) {
282
312
  if (!guardsSettled()) {
283
313
  if (event.type === "text_delta") {
284
314
  guardHeld.push(event);
@@ -354,9 +384,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
354
384
  const next = createCursorRequest(_parsed, { forceFreshConversation: true });
355
385
  rekeyContextUsage(failedConversationId, next.conversationId);
356
386
  _parsed._cursorConversationId = next.conversationId;
357
- if (_parsed._clientThreadId && _parsed._cursorIsolateConversation !== true) {
387
+ const threadOwner = cursorClientThreadOwner(_parsed);
388
+ if (threadOwner && _parsed._cursorIsolateConversation !== true) {
358
389
  rememberCursorThreadConversation(
359
- _parsed._clientThreadId,
390
+ threadOwner,
360
391
  next.conversationId,
361
392
  _parsed._cursorIdentityScope,
362
393
  );
@@ -369,50 +400,51 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
369
400
  await runOnce(request);
370
401
  break;
371
402
  } catch (err) {
372
- const outputGuardRetryText =
403
+ const outputGuardRetryText =
404
+ err instanceof CursorToolResultEchoError
405
+ ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT
406
+ : err instanceof CursorRoutingCommentaryError
407
+ ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT
408
+ : undefined;
409
+ // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11).
410
+ // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation
411
+ // retry is safe. A second rejection propagates as an error rather than looping.
412
+ if (
413
+ outputGuardRetryText
414
+ && !emittedOutput
415
+ && !replayUnsafe
416
+ && !incoming.abortSignal?.aborted
417
+ ) {
418
+ debugProviderDiagnostic(
419
+ "cursor",
373
420
  err instanceof CursorToolResultEchoError
374
- ? CURSOR_ECHO_RETRY_CONTINUATION_TEXT
375
- : err instanceof CursorRoutingCommentaryError
376
- ? CURSOR_ROUTING_COMMENTARY_RETRY_TEXT
377
- : undefined;
378
- // One-shot corrective retry for guarded external output (devlog 260826 gap-10/11).
379
- // The quarantine guarantees no client-visible delta escaped, so a fresh-conversation
380
- // retry is safe. A second rejection propagates as an error rather than looping.
381
- if (
382
- outputGuardRetryText
383
- && !emittedOutput
384
- && !replayUnsafe
385
- && !incoming.abortSignal?.aborted
386
- ) {
387
- debugProviderDiagnostic(
388
- "cursor",
389
- err instanceof CursorToolResultEchoError
390
- ? "envelope-echo-retry"
391
- : "routing-commentary-retry",
392
- {
393
- wireModel: request.modelId,
394
- conversationHash: request.conversationId.slice(0, 16),
395
- },
421
+ ? "envelope-echo-retry"
422
+ : "routing-commentary-retry",
423
+ {
424
+ wireModel: request.modelId,
425
+ conversationHash: request.conversationId.slice(0, 16),
426
+ },
427
+ );
428
+ const echoedConversationId = request.conversationId;
429
+ lastTransport = undefined;
430
+ _parsed._cursorConversationId = undefined;
431
+ request = {
432
+ ...createCursorRequest(_parsed, { forceFreshConversation: true }),
433
+ echoRetryContinuationText: outputGuardRetryText,
434
+ };
435
+ rekeyContextUsage(echoedConversationId, request.conversationId);
436
+ _parsed._cursorConversationId = request.conversationId;
437
+ const echoThreadOwner = cursorClientThreadOwner(_parsed);
438
+ if (echoThreadOwner && _parsed._cursorIsolateConversation !== true) {
439
+ rememberCursorThreadConversation(
440
+ echoThreadOwner,
441
+ request.conversationId,
442
+ _parsed._cursorIdentityScope,
396
443
  );
397
- const echoedConversationId = request.conversationId;
398
- lastTransport = undefined;
399
- _parsed._cursorConversationId = undefined;
400
- request = {
401
- ...createCursorRequest(_parsed, { forceFreshConversation: true }),
402
- echoRetryContinuationText: outputGuardRetryText,
403
- };
404
- rekeyContextUsage(echoedConversationId, request.conversationId);
405
- _parsed._cursorConversationId = request.conversationId;
406
- if (_parsed._clientThreadId && _parsed._cursorIsolateConversation !== true) {
407
- rememberCursorThreadConversation(
408
- _parsed._clientThreadId,
409
- request.conversationId,
410
- _parsed._cursorIdentityScope,
411
- );
412
- }
413
- await runOnce(request);
414
- break;
415
444
  }
445
+ await runOnce(request);
446
+ break;
447
+ } else {
416
448
  const overflowRemintSafe =
417
449
  !lastRawIsToolResult
418
450
  && !emittedOutput
@@ -422,25 +454,19 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
422
454
  _parsed,
423
455
  overflowRemintBaseId ?? request.conversationId,
424
456
  );
425
-
426
457
  if (
427
458
  overflowScopeKey
428
459
  && overflowRemintSafe
429
460
  && isCursorOverflowRemintCandidate(err, requestSizeContext)
430
461
  ) {
431
- if (shouldSkipCursorOverflowRemint(overflowScopeKey)) {
432
- throw err;
433
- }
462
+ if (shouldSkipCursorOverflowRemint(overflowScopeKey)) throw err;
434
463
  if (shouldSurfaceCursorOverflowFirst(overflowScopeKey)) {
435
464
  markCursorOverflowSurfaced(overflowScopeKey);
436
465
  throw err;
437
466
  }
438
- if (!recordCursorOverflowRemint(overflowScopeKey)) {
439
- throw err;
440
- }
441
- const failedConversationId = request.conversationId;
467
+ if (!recordCursorOverflowRemint(overflowScopeKey)) throw err;
442
468
  if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef);
443
- request = remintConversationId(failedConversationId);
469
+ request = remintConversationId(request.conversationId);
444
470
  continue;
445
471
  }
446
472
 
@@ -462,6 +488,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
462
488
  break;
463
489
  }
464
490
  }
491
+ }
465
492
  if (
466
493
  request.checkpointInvalidationReason
467
494
  && request.checkpointInvalidationReason !== "missing_ref"
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:f
3
3
  import { dirname, join } from "node:path";
4
4
  import { atomicWriteFileAsync, getConfigDir, type AtomicWriteAsyncTestSeam } from "../config";
5
5
  import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
6
+ import { ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL } from "./google-antigravity-wire";
6
7
 
7
8
  /**
8
9
  * Google-family thoughtSignature reasoning-replay cache.
@@ -19,6 +20,7 @@ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
19
20
 
20
21
  interface ReplayCall {
21
22
  signature: string;
23
+ signatures?: string[];
22
24
  sizeBytes: number;
23
25
  touchedAtMs: number;
24
26
  }
@@ -34,6 +36,7 @@ interface ReplayEntry {
34
36
  }
35
37
 
36
38
  const MIN_SIGNATURE_LEN = 16;
39
+ const MAX_SIGNATURES_PER_CALL = 32;
37
40
  const REPLAY_TTL_MS = 60 * 60 * 1000; // 1h
38
41
  export const ANTIGRAVITY_REPLAY_MAX_ENTRIES = 10_240;
39
42
  const REPLAY_EVICT_BATCH = 128;
@@ -103,16 +106,38 @@ function loadReplaySnapshotEntry(key: string, value: unknown): void {
103
106
  for (const pair of rec.byCall) {
104
107
  if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") continue;
105
108
  if (!/^[0-9a-f]{64}$/.test(pair[0])) continue;
106
- const call = pair[1] as { signature?: unknown; touchedAtMs?: unknown } | null;
109
+ const call = pair[1] as { signature?: unknown; signatures?: unknown; touchedAtMs?: unknown } | null;
107
110
  if (!call || typeof call !== "object" || Array.isArray(call)) continue;
108
111
  if (typeof call.signature !== "string" || call.signature.length < MIN_SIGNATURE_LEN) continue;
109
112
  if (typeof call.touchedAtMs !== "number" || !Number.isFinite(call.touchedAtMs)) continue;
110
- // Never trust a serialized sizeBytes: a forged snapshot could claim a tiny
111
- // size for a huge signature and bypass every byte cap. Recompute from the
112
- // signature itself, exactly like the live write path.
113
- const signatureBytes = utf8.encode(call.signature).byteLength;
114
- if (signatureBytes > replayLimits.maxSignatureBytes) continue;
115
- const callBytes = utf8.encode(pair[0]).byteLength + signatureBytes;
113
+ let sigs: string[] = [];
114
+ if (Array.isArray(call.signatures)) {
115
+ for (const s of call.signatures) {
116
+ if (typeof s === "string" && s.length >= MIN_SIGNATURE_LEN) {
117
+ sigs.push(s);
118
+ }
119
+ }
120
+ }
121
+ if (sigs.length === 0) {
122
+ sigs = [call.signature];
123
+ } else if (!sigs.includes(call.signature)) {
124
+ sigs.push(call.signature);
125
+ }
126
+ if (sigs.length > MAX_SIGNATURES_PER_CALL) {
127
+ sigs = sigs.slice(-MAX_SIGNATURES_PER_CALL);
128
+ }
129
+ let totalSigBytes = 0;
130
+ let anySigOversized = false;
131
+ for (const s of sigs) {
132
+ const sb = utf8.encode(s).byteLength;
133
+ if (sb > replayLimits.maxSignatureBytes) {
134
+ anySigOversized = true;
135
+ break;
136
+ }
137
+ totalSigBytes += sb;
138
+ }
139
+ if (anySigOversized) continue;
140
+ const callBytes = utf8.encode(pair[0]).byteLength + totalSigBytes;
116
141
  if (callBytes > replayLimits.maxBytesPerSession) continue;
117
142
  // A duplicated call key would overstate entry.bytes (the map keeps only the
118
143
  // last value) and could evict valid sessions; keep the first occurrence.
@@ -122,6 +147,7 @@ function loadReplaySnapshotEntry(key: string, value: unknown): void {
122
147
  }
123
148
  byCall.set(pair[0], {
124
149
  signature: call.signature,
150
+ signatures: sigs,
125
151
  sizeBytes: callBytes,
126
152
  touchedAtMs: call.touchedAtMs,
127
153
  });
@@ -226,7 +252,11 @@ async function persistReplaySnapshotNow(): Promise<void> {
226
252
  for (const [key, entry] of [...replayCache].sort((a, b) => b[1].lastActiveAtMs - a[1].lastActiveAtMs)) {
227
253
  const byCall = [...entry.byCall].map(([callKey, call]) => [
228
254
  callKey,
229
- { signature: call.signature, touchedAtMs: call.touchedAtMs },
255
+ {
256
+ signature: call.signature,
257
+ ...(call.signatures && call.signatures.length > 1 ? { signatures: call.signatures } : {}),
258
+ touchedAtMs: call.touchedAtMs,
259
+ },
230
260
  ]);
231
261
  const persistEntry: [string, unknown] = [key, {
232
262
  byCall,
@@ -483,10 +513,10 @@ export function antigravityReplaySessionKeysForTests(): string[] {
483
513
 
484
514
  function extractSignature(part: Record<string, unknown>): string | undefined {
485
515
  const direct = part.thoughtSignature ?? part.thought_signature;
486
- if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN) return direct;
516
+ if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN && direct !== ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL) return direct;
487
517
  const extra = part.extra_content as { google?: { thought_signature?: unknown } } | undefined;
488
518
  const nested = extra?.google?.thought_signature;
489
- if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN) return nested;
519
+ if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN && nested !== ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL) return nested;
490
520
  return undefined;
491
521
  }
492
522
 
@@ -644,10 +674,37 @@ export function observeAntigravityReplay(
644
674
  const ck = functionCallKey(fc.name, fc.args);
645
675
  if (!ck) continue; // only function-call signatures are replayable by identity
646
676
  const signatureBytes = utf8.encode(callSig).byteLength;
647
- const sizeBytes = utf8.encode(ck).byteLength + signatureBytes;
648
- if (signatureBytes > replayLimits.maxSignatureBytes || sizeBytes > replayLimits.maxBytesPerSession) continue;
677
+ if (signatureBytes > replayLimits.maxSignatureBytes) continue;
678
+
679
+ const existingCall = entry.byCall.get(ck);
680
+ let sigs: string[];
681
+ if (existingCall) {
682
+ sigs = existingCall.signatures && existingCall.signatures.length > 0
683
+ ? [...existingCall.signatures]
684
+ : [existingCall.signature];
685
+ if (!sigs.includes(callSig)) {
686
+ if (sigs.length >= MAX_SIGNATURES_PER_CALL) {
687
+ sigs.shift();
688
+ }
689
+ sigs.push(callSig);
690
+ }
691
+ } else {
692
+ sigs = [callSig];
693
+ }
694
+ let totalSigBytes = 0;
695
+ for (const s of sigs) {
696
+ totalSigBytes += utf8.encode(s).byteLength;
697
+ }
698
+ const sizeBytes = utf8.encode(ck).byteLength + totalSigBytes;
699
+ if (sizeBytes > replayLimits.maxBytesPerSession) continue;
700
+
649
701
  deleteReplayCall(entry, ck);
650
- entry.byCall.set(ck, { signature: callSig, sizeBytes, touchedAtMs: now });
702
+ entry.byCall.set(ck, {
703
+ signature: callSig,
704
+ signatures: sigs,
705
+ sizeBytes,
706
+ touchedAtMs: now,
707
+ });
651
708
  entry.bytes += sizeBytes;
652
709
  replayBytes += sizeBytes;
653
710
  inserted = true;
@@ -692,15 +749,22 @@ export function applyAntigravityReplay(model: string, sessionId: string, content
692
749
  if (!entry) {
693
750
  return contents;
694
751
  }
752
+
695
753
  let touched = false;
696
- for (const c of contents as { role?: string; parts?: unknown[] }[]) {
754
+ // Align from the END of the recorded signature list. History may have been truncated
755
+ // (compaction / previous_response_id): the last remaining occurrence is the most recent call,
756
+ // so it must receive the newest signature. Iterate backwards and count every occurrence
757
+ // (signed or not) so signed Mechanism-① parts still occupy their chronological slot.
758
+ const reverseOccurrence = new Map<string, number>();
759
+ for (let ci = (contents as { role?: string; parts?: unknown[] }[]).length - 1; ci >= 0; ci--) {
760
+ const c = (contents as { role?: string; parts?: unknown[] }[])[ci];
697
761
  if (!c || typeof c !== "object" || c.role !== "model" || !Array.isArray(c.parts)) continue;
698
- for (const raw of c.parts) {
762
+ for (let pi = c.parts.length - 1; pi >= 0; pi--) {
763
+ const raw = c.parts[pi];
699
764
  if (!raw || typeof raw !== "object") continue;
700
765
  const part = raw as Record<string, unknown>;
701
766
  const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined;
702
767
  if (!fc) continue;
703
- if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) continue;
704
768
  const ck = functionCallKey(fc.name, fc.args);
705
769
  let call = ck ? entry.byCall.get(ck) : undefined;
706
770
  let matchedKey = ck;
@@ -717,27 +781,43 @@ export function applyAntigravityReplay(model: string, sessionId: string, content
717
781
  ) {
718
782
  try {
719
783
  const parsedInput = JSON.parse(trimmedInput);
720
- if (parsedInput && typeof parsedInput === "object") {
721
- const altKey = functionCallKey(fc.name, parsedInput);
722
- if (altKey && entry.byCall.has(altKey)) {
723
- call = entry.byCall.get(altKey);
724
- matchedKey = altKey;
784
+ if (parsedInput && typeof parsedInput === "object") {
785
+ const altKey = functionCallKey(fc.name, parsedInput);
786
+ if (altKey && entry.byCall.has(altKey)) {
787
+ call = entry.byCall.get(altKey);
788
+ matchedKey = altKey;
789
+ }
725
790
  }
726
- }
727
791
  } catch {
728
792
  // not JSON, keep default
729
793
  }
730
794
  }
731
795
  }
732
796
  }
733
- if (call && matchedKey) {
797
+ if (matchedKey) {
798
+ const revIdx = reverseOccurrence.get(matchedKey) ?? 0;
799
+ reverseOccurrence.set(matchedKey, revIdx + 1);
800
+ if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) {
801
+ // Already signed (e.g. Mechanism ① or upstream response), preserve it.
802
+ continue;
803
+ }
804
+ if (call) {
805
+ const sigs = call.signatures && call.signatures.length > 0 ? call.signatures : [call.signature];
806
+ const chosenSig = revIdx < sigs.length
807
+ ? sigs[sigs.length - 1 - revIdx]
808
+ : sigs[sigs.length - 1] ?? call.signature;
809
+ part.thoughtSignature = chosenSig;
810
+ entry.byCall.delete(matchedKey);
811
+ entry.byCall.set(matchedKey, { ...call, touchedAtMs: now });
812
+ touched = true;
813
+ }
814
+ } else if (part.thoughtSignature === undefined && part.thought_signature === undefined && call) {
734
815
  part.thoughtSignature = call.signature;
735
- entry.byCall.delete(matchedKey);
736
- entry.byCall.set(matchedKey, { ...call, touchedAtMs: now });
737
816
  touched = true;
738
817
  }
739
818
  }
740
819
  }
820
+
741
821
  if (touched) {
742
822
  entry.lastActiveAtMs = now;
743
823
  refreshReplaySessionCandidate(replayKey(model, sessionId), entry);
@@ -36,6 +36,11 @@ export const ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL = "skip_thought_signature_val
36
36
  */
37
37
  export function isLikelyRealThoughtSignature(sig: string | undefined): boolean {
38
38
  if (typeof sig !== "string" || sig.length < 16) return false;
39
+ // The validator-bypass sentinel is something WE fabricate for outbound requests when no real
40
+ // signature exists. It is alphanumeric with underscores, so it would otherwise satisfy every
41
+ // check below and be re-ingested as genuine — cached, replayed, and eventually treated as
42
+ // evidence that a turn was signed. It is never a real signature.
43
+ if (sig === ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL) return false;
39
44
  // Reject synthetic Responses/tool-call ids and Anthropic tool-use ids (`_` or `-` separators).
40
45
  if (/^(fc|ctc|tsc|call|msg|rs|resp|reasoning|item|ws|toolu|tool|func|function)[-_]/i.test(sig)) return false;
41
46
  // Real Gemini thought signatures are opaque base64/base64url blobs: only [A-Za-z0-9+/_=-].
@@ -18,6 +18,43 @@ function googleErrorDetail(payloadText: string): { message?: string; status?: st
18
18
  };
19
19
  }
20
20
 
21
+ const GOOGLE_QUOTA_EXHAUSTED_NEEDLES = [
22
+ "quotafailure",
23
+ "quota exceeded",
24
+ "exceeded your current quota",
25
+ "billing",
26
+ "individual quota reached",
27
+ "quota reached",
28
+ "enable overages",
29
+ "exhausted your capacity",
30
+ "daily limit reached",
31
+ "weekly limit reached",
32
+ ];
33
+
34
+ // Per-minute / per-second / concurrency limits are transient rate limits: they should be
35
+ // retried, not treated as hard quota exhaustion. These guards run before the needles so a
36
+ // message like "Per-minute quota exceeded" stays in the retryable bucket.
37
+ const GOOGLE_TRANSIENT_RATE_LIMIT_PATTERNS = [
38
+ "per minute",
39
+ "per-minute",
40
+ "per min",
41
+ "rpm",
42
+ "requests per minute",
43
+ "too many requests",
44
+ "rate limit",
45
+ "retry after",
46
+ // Upstream writes the header name both ways in prose ("retry-after: 60"); matching only the
47
+ // spaced spelling let a transient 429 fall through to the exhaustion needles below.
48
+ "retry-after",
49
+ "concurrent request limit",
50
+ ];
51
+
52
+ export function isGoogleQuotaExhaustedText(text: string): boolean {
53
+ const lower = text.toLowerCase();
54
+ if (GOOGLE_TRANSIENT_RATE_LIMIT_PATTERNS.some(needle => lower.includes(needle))) return false;
55
+ return GOOGLE_QUOTA_EXHAUSTED_NEEDLES.some(needle => lower.includes(needle));
56
+ }
57
+
21
58
  const ANTIGRAVITY_GEO_BLOCKED_MARKER = "user location is not supported for the api use";
22
59
 
23
60
  export function isAntigravityGeoBlockedBody(payloadText: string): boolean {
@@ -27,12 +64,8 @@ export function isAntigravityGeoBlockedBody(payloadText: string): boolean {
27
64
 
28
65
  function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string {
29
66
  const lower = `${enumStatus ?? ""} ${text}`.toLowerCase();
30
- const quotaExhausted =
31
- lower.includes("quotafailure") ||
32
- lower.includes("quota exceeded") ||
33
- lower.includes("exceeded your current quota") ||
34
- lower.includes("billing");
35
- if (enumStatus === "RESOURCE_EXHAUSTED" && quotaExhausted) return `${label} quota exhausted`;
67
+ const quotaExhausted = isGoogleQuotaExhaustedText(lower);
68
+ if ((!enumStatus || enumStatus === "RESOURCE_EXHAUSTED") && quotaExhausted) return `${label} quota exhausted`;
36
69
  if (status === 429 || enumStatus === "RESOURCE_EXHAUSTED" || lower.includes("rate limit")) {
37
70
  return `${label} rate limit exceeded`;
38
71
  }
@@ -87,10 +120,6 @@ export function retryableGoogleStatus(status: number): boolean {
87
120
  */
88
121
  export function isQuotaExhaustedBody(payloadText: string): boolean {
89
122
  const { message, status } = googleErrorDetail(payloadText);
90
- if (status !== "RESOURCE_EXHAUSTED") return false;
91
- const lower = (message ?? "").toLowerCase();
92
- return lower.includes("quotafailure")
93
- || lower.includes("quota exceeded")
94
- || lower.includes("exceeded your current quota")
95
- || lower.includes("billing");
123
+ if (status && status !== "RESOURCE_EXHAUSTED") return false;
124
+ return isGoogleQuotaExhaustedText(message ?? payloadText);
96
125
  }