@bitkyc08/opencodex 2.57.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 (86) hide show
  1. package/gui/dist/assets/{index-Cz7CLdif.js → index-BbrHOIY0.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  5. package/src/adapters/command-code.ts +13 -4
  6. package/src/adapters/cursor/cursor-errors.ts +15 -0
  7. package/src/adapters/cursor/discovery.ts +65 -1
  8. package/src/adapters/cursor/live-transport.ts +5 -1
  9. package/src/adapters/cursor/protobuf-events.ts +110 -11
  10. package/src/adapters/cursor/protobuf-request.ts +19 -1
  11. package/src/adapters/cursor/text-toolcall.ts +230 -0
  12. package/src/adapters/cursor/thread-continuity.ts +67 -0
  13. package/src/adapters/cursor/types.ts +5 -0
  14. package/src/adapters/cursor.ts +55 -5
  15. package/src/adapters/google-http.ts +38 -13
  16. package/src/adapters/mimo-free.ts +32 -17
  17. package/src/adapters/ollama-native.ts +42 -8
  18. package/src/adapters/openai-responses/passthrough.ts +30 -4
  19. package/src/adapters/openai-responses/request-strips.ts +43 -0
  20. package/src/adapters/physical-send.ts +50 -0
  21. package/src/bridge/response-json.ts +1 -1
  22. package/src/bridge/sse.ts +1 -1
  23. package/src/claude/outbound.ts +14 -4
  24. package/src/cli/config-command.ts +35 -18
  25. package/src/cli/dispatch.ts +17 -4
  26. package/src/cli/index.ts +44 -2
  27. package/src/cli/system-command.ts +70 -1
  28. package/src/cli/uninstall-client-state.ts +12 -0
  29. package/src/codex/auth-context.ts +42 -8
  30. package/src/codex/desktop-switches.ts +145 -0
  31. package/src/codex/history-job.ts +5 -1
  32. package/src/codex/history-provider.ts +33 -4
  33. package/src/codex/history-worker.ts +14 -1
  34. package/src/codex/inject/remove.ts +145 -7
  35. package/src/codex/inject/restore.ts +204 -32
  36. package/src/codex/inject.ts +3 -7
  37. package/src/codex/loopback-target.ts +9 -0
  38. package/src/codex/native-profile-startup.ts +64 -20
  39. package/src/config/atomic-write.ts +83 -8
  40. package/src/config/schema/config-schema.ts +2 -0
  41. package/src/config/schema/leaf-validators.ts +1 -0
  42. package/src/generated/compatibility-version.json +132 -68
  43. package/src/lib/bounded-subprocess.ts +62 -10
  44. package/src/lib/windows-secret-acl.ts +151 -15
  45. package/src/lib/windows-user-principal.ts +5 -1
  46. package/src/providers/derive.ts +6 -0
  47. package/src/providers/model-discovery.ts +19 -7
  48. package/src/providers/registry/entries-core.ts +11 -0
  49. package/src/providers/registry/entries-extended.ts +50 -28
  50. package/src/providers/registry/model-seeds.ts +67 -17
  51. package/src/providers/registry/types.ts +9 -0
  52. package/src/responses/spill-store.ts +17 -0
  53. package/src/responses/state/body-policy.ts +25 -0
  54. package/src/responses/state/spill-queue.ts +8 -6
  55. package/src/responses/state.ts +3 -22
  56. package/src/router.ts +4 -0
  57. package/src/server/auth-cors.ts +1 -0
  58. package/src/server/index/websocket-handler.ts +48 -1
  59. package/src/server/management/config-routes.ts +27 -5
  60. package/src/server/models-capabilities.ts +24 -3
  61. package/src/server/responses/codex-ws-exchange.ts +65 -4
  62. package/src/server/responses/combo-stream-preflight.ts +68 -5
  63. package/src/server/responses/core-combo.ts +26 -0
  64. package/src/server/responses/core-options.ts +3 -0
  65. package/src/server/responses/fetch-helpers.ts +4 -1
  66. package/src/server/responses/native-injection-protocol.ts +42 -0
  67. package/src/server/responses/native-injection-replay.ts +105 -0
  68. package/src/server/responses/native-injection.ts +242 -0
  69. package/src/server/responses/native-response-control.ts +56 -0
  70. package/src/server/responses/native-response-json.ts +14 -0
  71. package/src/server/responses/native-response-output.ts +37 -0
  72. package/src/server/responses/native-steering-log.ts +44 -0
  73. package/src/server/responses/native-steering-policy.ts +49 -0
  74. package/src/server/responses/native-steering-replay.ts +126 -0
  75. package/src/server/responses/native-steering-settings.ts +76 -0
  76. package/src/server/responses/native-steering.ts +400 -0
  77. package/src/server/responses/native-tool-results.ts +130 -0
  78. package/src/server/responses/passthrough-delivery.ts +11 -0
  79. package/src/server/responses/passthrough-dispatch.ts +33 -1
  80. package/src/server/responses/request-prepare.ts +41 -0
  81. package/src/server/responses/ws-upstream.ts +21 -1
  82. package/src/server/stop-teardown.ts +8 -1
  83. package/src/server/ws-bridge.ts +16 -1
  84. package/src/service/cli.ts +13 -1
  85. package/src/types/config.ts +4 -0
  86. package/src/types/provider.ts +13 -0
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Quarantine textual pseudo tool-call markers that Cursor models emit inside
3
+ * `textDelta` instead of (or in addition to) real `toolCall*` frames.
4
+ *
5
+ * `#2305` only rewrote the display alias *inside* `[TOOL_CALL]…[ARGS]`. The
6
+ * marker still reached Codex/Claude as assistant text, which few-shot-mimics
7
+ * later calls as inert text and stalls multi-tool turns. This drain strips
8
+ * every complete marker from visible text and yields the parsed calls so the
9
+ * protobuf mapper can promote advertised names onto the real tool-call path.
10
+ *
11
+ * Incomplete markers (split across streaming deltas) stay in `pending` until
12
+ * the JSON object closes. Once the retained prefix exceeds the byte cap, the
13
+ * parser switches to a constant-space suppressed scan until the object closes.
14
+ */
15
+ import { debugProviderDiagnostic } from "../../lib/debug";
16
+ import { normalizeCursorWireName } from "./tool-naming";
17
+
18
+ export const MAX_PENDING_TEXT_TOOLCALL_BYTES = 64 * 1024;
19
+ const TOOL_CALL_OPEN = /\[TOOL_CALL\]/gi;
20
+
21
+ export interface DrainedTextToolCall {
22
+ readonly name: string;
23
+ readonly args: string;
24
+ }
25
+
26
+ export interface SuppressedTextToolCallScan {
27
+ phase: "args-tag" | "json-start" | "json";
28
+ argsTagProgress: number;
29
+ depth: number;
30
+ inString: boolean;
31
+ escape: boolean;
32
+ }
33
+
34
+ export interface DrainTextToolCallsResult {
35
+ readonly text: string;
36
+ readonly pending: string;
37
+ readonly calls: readonly DrainedTextToolCall[];
38
+ readonly suppressed?: SuppressedTextToolCallScan;
39
+ }
40
+
41
+ function findJsonObjectEnd(source: string, start: number): number | undefined {
42
+ if (source[start] !== "{") return undefined;
43
+ let depth = 0;
44
+ let inString = false;
45
+ let escape = false;
46
+ for (let i = start; i < source.length; i++) {
47
+ const ch = source[i];
48
+ if (inString) {
49
+ if (escape) {
50
+ escape = false;
51
+ continue;
52
+ }
53
+ if (ch === "\\") {
54
+ escape = true;
55
+ continue;
56
+ }
57
+ if (ch === "\"") inString = false;
58
+ continue;
59
+ }
60
+ if (ch === "\"") {
61
+ inString = true;
62
+ continue;
63
+ }
64
+ if (ch === "{") depth += 1;
65
+ else if (ch === "}") {
66
+ depth -= 1;
67
+ if (depth === 0) return i + 1;
68
+ }
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ function byteLength(text: string): number {
74
+ return new TextEncoder().encode(text).byteLength;
75
+ }
76
+
77
+ function newSuppressedScan(): SuppressedTextToolCallScan {
78
+ return {
79
+ phase: "args-tag",
80
+ argsTagProgress: 0,
81
+ depth: 0,
82
+ inString: false,
83
+ escape: false,
84
+ };
85
+ }
86
+
87
+ const ARGS_TAG = "[args]";
88
+
89
+ function consumeSuppressedScan(
90
+ state: SuppressedTextToolCallScan,
91
+ source: string,
92
+ start = 0,
93
+ ): { readonly state?: SuppressedTextToolCallScan; readonly resumeAt: number } {
94
+ for (let i = start; i < source.length; i++) {
95
+ const ch = source[i] ?? "";
96
+ if (state.phase === "args-tag") {
97
+ const lower = ch.toLowerCase();
98
+ if (lower === ARGS_TAG[state.argsTagProgress]) {
99
+ state.argsTagProgress += 1;
100
+ if (state.argsTagProgress === ARGS_TAG.length) {
101
+ state.phase = "json-start";
102
+ state.argsTagProgress = 0;
103
+ }
104
+ } else {
105
+ state.argsTagProgress = lower === ARGS_TAG[0] ? 1 : 0;
106
+ }
107
+ continue;
108
+ }
109
+ if (state.phase === "json-start") {
110
+ if (/\s/.test(ch)) continue;
111
+ if (ch !== "{") {
112
+ debugProviderDiagnostic("cursor", "text-toolcall-invalid-arguments", {
113
+ reason: "arguments-did-not-start-with-object",
114
+ });
115
+ return { resumeAt: source.length };
116
+ }
117
+ state.phase = "json";
118
+ state.depth = 1;
119
+ continue;
120
+ }
121
+ if (state.inString) {
122
+ if (state.escape) {
123
+ state.escape = false;
124
+ } else if (ch === "\\") {
125
+ state.escape = true;
126
+ } else if (ch === "\"") {
127
+ state.inString = false;
128
+ }
129
+ continue;
130
+ }
131
+ if (ch === "\"") state.inString = true;
132
+ else if (ch === "{") state.depth += 1;
133
+ else if (ch === "}") {
134
+ state.depth -= 1;
135
+ if (state.depth === 0) return { resumeAt: i + 1 };
136
+ }
137
+ }
138
+ return { state, resumeAt: source.length };
139
+ }
140
+
141
+ function holdOrSuppress(hold: string, afterOpen: number): Pick<DrainTextToolCallsResult, "pending" | "suppressed"> {
142
+ if (byteLength(hold) <= MAX_PENDING_TEXT_TOOLCALL_BYTES) return { pending: hold };
143
+ const suppressed = newSuppressedScan();
144
+ const scanned = consumeSuppressedScan(suppressed, hold, afterOpen);
145
+ return scanned.state ? { pending: "", suppressed: scanned.state } : { pending: "" };
146
+ }
147
+
148
+ /**
149
+ * Fold `pending + chunk`, emit surrounding prose, and extract every complete
150
+ * `[TOOL_CALL]name[ARGS]{…}` block. Names are folded through
151
+ * `normalizeCursorWireName` so `mcp_opencodex-responses_*` display aliases
152
+ * become the advertised wire name before promotion.
153
+ */
154
+ export function drainCursorTextToolCalls(
155
+ pending: string,
156
+ chunk: string,
157
+ suppressed?: SuppressedTextToolCallScan,
158
+ ): DrainTextToolCallsResult {
159
+ let combined = pending + chunk;
160
+ if (suppressed) {
161
+ const scanned = consumeSuppressedScan(suppressed, chunk);
162
+ if (scanned.state) return { text: "", pending: "", calls: [], suppressed: scanned.state };
163
+ combined = chunk.slice(scanned.resumeAt);
164
+ }
165
+ let cursor = 0;
166
+ let text = "";
167
+ const calls: DrainedTextToolCall[] = [];
168
+ const opener = new RegExp(TOOL_CALL_OPEN.source, TOOL_CALL_OPEN.flags);
169
+
170
+ while (cursor < combined.length) {
171
+ opener.lastIndex = cursor;
172
+ const match = opener.exec(combined);
173
+ if (!match || match.index === undefined) {
174
+ text += combined.slice(cursor);
175
+ return { text, pending: "", calls };
176
+ }
177
+
178
+ text += combined.slice(cursor, match.index);
179
+ const afterOpen = match.index + match[0].length;
180
+ const rest = combined.slice(afterOpen);
181
+ const argsTag = rest.match(/^([^\[\]]*)\[ARGS\]/i);
182
+ if (!argsTag) {
183
+ const hold = combined.slice(match.index);
184
+ return { text, calls, ...holdOrSuppress(hold, afterOpen - match.index) };
185
+ }
186
+
187
+ const name = argsTag[1]?.trim() ?? "";
188
+ let jsonStart = afterOpen + argsTag[0].length;
189
+ while (jsonStart < combined.length && /\s/.test(combined[jsonStart] ?? "")) jsonStart += 1;
190
+ if (jsonStart >= combined.length) {
191
+ const hold = combined.slice(match.index);
192
+ return { text, calls, ...holdOrSuppress(hold, afterOpen - match.index) };
193
+ }
194
+ if (combined[jsonStart] !== "{") {
195
+ debugProviderDiagnostic("cursor", "text-toolcall-invalid-arguments", {
196
+ reason: "arguments-did-not-start-with-object",
197
+ ...(name ? { toolName: normalizeCursorWireName(name) } : {}),
198
+ });
199
+ // No delimiter identifies where a non-JSON payload ends. Resume marker
200
+ // scanning after `[ARGS]`, while suppressing the malformed payload itself.
201
+ opener.lastIndex = jsonStart;
202
+ const nextMarker = opener.exec(combined);
203
+ if (!nextMarker || nextMarker.index === undefined) return { text, pending: "", calls };
204
+ cursor = nextMarker.index;
205
+ continue;
206
+ }
207
+
208
+ const jsonEnd = findJsonObjectEnd(combined, jsonStart);
209
+ if (jsonEnd === undefined) {
210
+ const hold = combined.slice(match.index);
211
+ return { text, calls, ...holdOrSuppress(hold, afterOpen - match.index) };
212
+ }
213
+
214
+ const args = combined.slice(jsonStart, jsonEnd);
215
+ try {
216
+ JSON.parse(args);
217
+ if (name.length > 0) {
218
+ calls.push({ name: normalizeCursorWireName(name), args });
219
+ }
220
+ } catch {
221
+ debugProviderDiagnostic("cursor", "text-toolcall-invalid-arguments", {
222
+ reason: "arguments-json-parse-failed",
223
+ ...(name ? { toolName: normalizeCursorWireName(name) } : {}),
224
+ });
225
+ }
226
+ cursor = jsonEnd;
227
+ }
228
+
229
+ return { text, pending: "", calls };
230
+ }
@@ -158,3 +158,70 @@ export function cursorOverflowRemintCountForTests(): number {
158
158
  pruneOverflowRemints(now());
159
159
  return overflowRemintByScope.size;
160
160
  }
161
+
162
+ /** Max next-turn conversation-id rotations after incomplete client-tool streams per retained scope. */
163
+ export const CURSOR_INCOMPLETE_TOOL_REMINT_MAX = 3;
164
+ export const CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS = CURSOR_OVERFLOW_REMINT_TTL_MS;
165
+ export const CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES = CURSOR_OVERFLOW_REMINT_MAX_ENTRIES;
166
+
167
+ type IncompleteToolRemintState = {
168
+ remintCount: number;
169
+ updatedAt: number;
170
+ };
171
+
172
+ const incompleteToolRemintByScope = new Map<string, IncompleteToolRemintState>();
173
+
174
+ function pruneIncompleteToolRemints(at: number): void {
175
+ for (const [scopeKey, entry] of incompleteToolRemintByScope) {
176
+ if (at - entry.updatedAt > CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS) {
177
+ incompleteToolRemintByScope.delete(scopeKey);
178
+ }
179
+ }
180
+ while (incompleteToolRemintByScope.size > CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES) {
181
+ const oldest = incompleteToolRemintByScope.keys().next().value;
182
+ if (oldest === undefined) break;
183
+ incompleteToolRemintByScope.delete(oldest);
184
+ }
185
+ }
186
+
187
+ /** Incomplete-tool and overflow recovery share ownership scope, but keep independent budgets. */
188
+ export function cursorIncompleteToolRemintScopeKey(
189
+ threadOwner: string | undefined,
190
+ identityScope?: string,
191
+ ): string | null {
192
+ return cursorOverflowRemintScopeKey(threadOwner, identityScope);
193
+ }
194
+
195
+ /** Record one incomplete-tool remint; returns false when the independent cap is exhausted. */
196
+ export function recordCursorIncompleteToolRemint(scopeKey: string): boolean {
197
+ const at = now();
198
+ pruneIncompleteToolRemints(at);
199
+ const existing = incompleteToolRemintByScope.get(scopeKey);
200
+ if (existing && existing.remintCount >= CURSOR_INCOMPLETE_TOOL_REMINT_MAX) {
201
+ existing.updatedAt = at;
202
+ incompleteToolRemintByScope.delete(scopeKey);
203
+ incompleteToolRemintByScope.set(scopeKey, existing);
204
+ return false;
205
+ }
206
+ const entry = existing ?? { remintCount: 0, updatedAt: at };
207
+ entry.remintCount += 1;
208
+ entry.updatedAt = at;
209
+ incompleteToolRemintByScope.delete(scopeKey);
210
+ incompleteToolRemintByScope.set(scopeKey, entry);
211
+ pruneIncompleteToolRemints(at);
212
+ return true;
213
+ }
214
+
215
+ /** A clean turn replenishes this recovery without changing the overflow retry budget. */
216
+ export function clearCursorIncompleteToolRemint(scopeKey: string): void {
217
+ incompleteToolRemintByScope.delete(scopeKey);
218
+ }
219
+
220
+ export function clearCursorIncompleteToolRemintForTests(): void {
221
+ incompleteToolRemintByScope.clear();
222
+ }
223
+
224
+ export function cursorIncompleteToolRemintCountForTests(): number {
225
+ pruneIncompleteToolRemints(now());
226
+ return incompleteToolRemintByScope.size;
227
+ }
@@ -11,6 +11,11 @@ export interface CursorRequestedModelParameter {
11
11
 
12
12
  export interface CursorRunRequest {
13
13
  modelId: string;
14
+ /**
15
+ * Normalized Cursor identity scope carried from request parsing so checkpoint
16
+ * observations stay scope-local. Transport metadata only; never serialized on the wire.
17
+ */
18
+ _cursorIdentityScope?: string;
14
19
  /** Cursor model-picker parameters encoded through AgentRunRequest.requested_model. */
15
20
  requestedModelParameters?: readonly CursorRequestedModelParameter[];
16
21
  /** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */
@@ -3,7 +3,7 @@ import type { AdapterEvent, 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 { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
6
+ import { isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
7
7
  import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
8
8
  import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
9
9
  import { mapCursorServerMessage } from "./cursor/message-mapper";
@@ -32,8 +32,11 @@ import { isDebugEnabled } from "../lib/debug-settings";
32
32
  import { createAdapterTierMetadata } from "../providers/fastwire";
33
33
  import { estimateTokens } from "../lib/token-estimate";
34
34
  import {
35
+ clearCursorIncompleteToolRemint,
36
+ cursorIncompleteToolRemintScopeKey,
35
37
  cursorOverflowRemintScopeKey,
36
38
  markCursorOverflowSurfaced,
39
+ recordCursorIncompleteToolRemint,
37
40
  recordCursorOverflowRemint,
38
41
  rememberCursorThreadConversation,
39
42
  shouldSkipCursorOverflowRemint,
@@ -99,11 +102,20 @@ function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext)
99
102
  * estimate over the outgoing text vs the model's context window. Only used to keep
100
103
  * SMALL requests on the 429 class — unknown/large stays on the overflow mapping.
101
104
  */
102
- function cursorRequestSizeContext(request: { modelId: string; system: string[]; messages: { content: string }[] }): CursorSizeContext {
105
+ function cursorRequestSizeContext(request: {
106
+ modelId: string;
107
+ _cursorIdentityScope?: string;
108
+ system: string[];
109
+ messages: { content: string }[];
110
+ }): CursorSizeContext {
103
111
  const text = [...request.system, ...request.messages.map(message => message.content)].join("\n");
104
112
  return {
105
113
  estimatedInputTokens: estimateTokens(text, request.modelId),
106
- contextWindow: inferCursorContextWindow(request.modelId),
114
+ // Prefers this identity scope's checkpoint `maxTokens` over the id heuristic
115
+ // so a plan-gated ceiling participates in the 0.5-window overflow vs 429 prior.
116
+ contextWindow: inferCursorContextWindow(request.modelId, {
117
+ identityScope: request._cursorIdentityScope,
118
+ }),
107
119
  };
108
120
  }
109
121
 
@@ -171,7 +183,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
171
183
  }
172
184
  const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef;
173
185
  const previousConversationId = _parsed._cursorConversationId;
174
- let request = createCursorRequest(_parsed);
186
+ let request = {
187
+ ...createCursorRequest(_parsed),
188
+ _cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local",
189
+ };
175
190
  requestSizeContext = cursorRequestSizeContext(request);
176
191
  // The builder may derive a stable provider id from the client thread when Responses state
177
192
  // is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn,
@@ -190,6 +205,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
190
205
  let completedNormally = false;
191
206
  let lastTransport: { captured?: Uint8Array } | undefined;
192
207
  let emittedClientTool = false;
208
+ let sawIncompleteToolCall = false;
193
209
  // Ordering proof for tool-suspended checkpoints: true only when the newest captured
194
210
  // checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream
195
211
  // serialized its suspended-on-tool-call state. Only that snapshot can safely resume
@@ -332,6 +348,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
332
348
  },
333
349
  });
334
350
  for (const event of events) {
351
+ if (event.type === "error" && isCursorIncompleteToolCallMessage(event.message)) {
352
+ sawIncompleteToolCall = true;
353
+ }
335
354
  if (!guardsSettled()) {
336
355
  if (event.type === "text_delta") {
337
356
  guardHeld.push(event);
@@ -413,7 +432,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
413
432
  const remintConversationId = (failedConversationId: string) => {
414
433
  lastTransport = undefined;
415
434
  _parsed._cursorConversationId = undefined;
416
- const next = createCursorRequest(_parsed, { forceFreshConversation: true });
435
+ const next = {
436
+ ...createCursorRequest(_parsed, { forceFreshConversation: true }),
437
+ _cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local",
438
+ };
417
439
  rekeyContextUsage(failedConversationId, next.conversationId);
418
440
  _parsed._cursorConversationId = next.conversationId;
419
441
  // Persist recovery for store:false clients that send any stable Cursor thread owner, so
@@ -514,6 +536,34 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
514
536
  }
515
537
  }
516
538
  }
539
+ const incompleteToolRemintScopeKey =
540
+ _parsed._cursorIsolateConversation !== true
541
+ && request.contextUsageStoreCheckpoints !== false
542
+ ? cursorIncompleteToolRemintScopeKey(
543
+ cursorClientThreadOwner(_parsed),
544
+ _parsed._cursorIdentityScope,
545
+ )
546
+ : null;
547
+ // Incomplete-tool errors are streamed, not thrown. Do not retry this turn; rotate only
548
+ // the next turn's id. request-prepare currently isolates compaction, but adapter callers
549
+ // can bypass that upstream invariant, so checkpoint storage is the local isolation boundary.
550
+ if (sawIncompleteToolCall && incompleteToolRemintScopeKey) {
551
+ if (recordCursorIncompleteToolRemint(incompleteToolRemintScopeKey)) {
552
+ if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef);
553
+ debugProviderDiagnostic("cursor", "incomplete-tool-remint", {
554
+ wireModel: request.modelId,
555
+ conversationHash: request.conversationId.slice(0, 16),
556
+ });
557
+ remintConversationId(request.conversationId);
558
+ } else {
559
+ debugProviderDiagnostic("cursor", "incomplete-tool-remint-exhausted", {
560
+ wireModel: request.modelId,
561
+ conversationHash: request.conversationId.slice(0, 16),
562
+ });
563
+ }
564
+ } else if (!sawIncompleteToolCall && completedNormally && incompleteToolRemintScopeKey) {
565
+ clearCursorIncompleteToolRemint(incompleteToolRemintScopeKey);
566
+ }
517
567
  if (
518
568
  request.checkpointInvalidationReason
519
569
  && request.checkpointInvalidationReason !== "missing_ref"
@@ -1,4 +1,7 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
+ import { createAdapterPhysicalSend } from "./physical-send";
3
+ import type { SendClass } from "../lib/request-execution-budget";
4
+ import type { AttemptRecoveryKind } from "../usage/log";
2
5
  import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
3
6
  import { repairGoogleInvalidRequestBody } from "./google-wire-compiler";
4
7
  import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error";
@@ -8,6 +11,8 @@ import {
8
11
  fetchWithAttemptDeadline,
9
12
  retryBackoffDelayMs,
10
13
  sleepWithAbort,
14
+ SendBudgetExhaustedError,
15
+ isConnectionResetError,
11
16
  } from "../lib/upstream-retry";
12
17
 
13
18
  const GOOGLE_RETRY_ATTEMPTS = 3;
@@ -41,18 +46,30 @@ export async function fetchGoogleWithRetry(
41
46
  ): Promise<Response> {
42
47
  const repairInvalid400 = opts.repairInvalid400 ?? true;
43
48
  const timeoutMs = ctx.timeoutMs ?? 200_000;
44
- const executor = ctx.executor ?? globalThis.fetch;
49
+ const send = createAdapterPhysicalSend(ctx);
45
50
  let lastError: unknown;
46
51
  let activeRequest = request;
47
52
  let compatibilityReplayUsed = false;
53
+ let pendingResponse: Response | undefined;
54
+ let retryDelayMs = 0;
55
+ let sendClass: SendClass = "transient";
56
+ let recovery: AttemptRecoveryKind | undefined;
48
57
  for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) {
49
58
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
50
59
  try {
51
- const res = await fetchWithAttemptDeadline(activeRequest.url, {
52
- method: activeRequest.method,
53
- headers: activeRequest.headers,
54
- body: activeRequest.body,
55
- }, timeoutMs, ctx.abortSignal, ctx.stream, executor);
60
+ const res = await send({ url: activeRequest.url, sendClass, recovery,
61
+ beforeDispatch: async () => {
62
+ if (retryDelayMs > 0) await sleepWithAbort(retryDelayMs, ctx.abortSignal);
63
+ if (pendingResponse) cancelResponseBodyBestEffort(pendingResponse);
64
+ pendingResponse = undefined;
65
+ },
66
+ dispatch: executor => fetchWithAttemptDeadline(activeRequest.url, {
67
+ method: activeRequest.method, headers: activeRequest.headers, body: activeRequest.body,
68
+ }, timeoutMs, ctx.abortSignal, ctx.stream, executor),
69
+ });
70
+ retryDelayMs = 0;
71
+ sendClass = "transient";
72
+ recovery = undefined;
56
73
  if (res.status === 400 && repairInvalid400 && !compatibilityReplayUsed) {
57
74
  let payloadText = "";
58
75
  try {
@@ -64,7 +81,8 @@ export async function fetchGoogleWithRetry(
64
81
  if (repairedBody !== undefined) {
65
82
  compatibilityReplayUsed = true;
66
83
  activeRequest = { ...activeRequest, body: repairedBody };
67
- cancelResponseBodyBestEffort(res);
84
+ pendingResponse = res;
85
+ sendClass = "repair";
68
86
  attempt--; // The changed-request replay is separate from transient retry accounting.
69
87
  continue;
70
88
  }
@@ -75,7 +93,7 @@ export async function fetchGoogleWithRetry(
75
93
  // A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry —
76
94
  // it won't recover for hours and burns retries). Peek the body to tell them apart.
77
95
  if (res.status === 429) {
78
- const peekTarget = ctx.returnRawErrors ? res.clone() : res;
96
+ const peekTarget = res.clone();
79
97
  const peek = await readDisplaySafeErrorPayloadText(peekTarget, ctx.abortSignal);
80
98
  if (isQuotaExhaustedBody(peek)) {
81
99
  return ctx.returnRawErrors ? res : normalizeUpstreamHttpErrorResponse(res, {
@@ -84,20 +102,27 @@ export async function fetchGoogleWithRetry(
84
102
  });
85
103
  }
86
104
  }
87
- cancelResponseBodyBestEffort(res);
88
- await sleepWithAbort(retryBackoffDelayMs(attempt, {
105
+ pendingResponse = res;
106
+ recovery = res.status === 429 ? "rate-limit-429" : "transient-5xx";
107
+ retryDelayMs = retryBackoffDelayMs(attempt, {
89
108
  baseDelayMs: GOOGLE_RETRY_BASE_MS,
90
109
  maxDelayMs: GOOGLE_RETRY_MAX_MS,
91
110
  headers: res.headers,
92
- }), ctx.abortSignal);
111
+ });
93
112
  } catch (err) {
94
113
  if (ctx.abortSignal?.aborted) throw err;
114
+ if (err instanceof SendBudgetExhaustedError) {
115
+ if (pendingResponse) return ctx.returnRawErrors ? pendingResponse : normalizeFinalGoogleError(label, pendingResponse, ctx.abortSignal);
116
+ throw err;
117
+ }
95
118
  lastError = err;
96
119
  if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err;
97
- await sleepWithAbort(retryBackoffDelayMs(attempt, {
120
+ sendClass = "transient";
121
+ recovery = isConnectionResetError(err) ? "connection-reset" : undefined;
122
+ retryDelayMs = retryBackoffDelayMs(attempt, {
98
123
  baseDelayMs: GOOGLE_RETRY_BASE_MS,
99
124
  maxDelayMs: GOOGLE_RETRY_MAX_MS,
100
- }), ctx.abortSignal);
125
+ });
101
126
  }
102
127
  }
103
128
  throw lastError ?? new Error(`${label} fetch failed`);
@@ -6,6 +6,8 @@ import { recordOwnedConfigPath } from "../lib/config-ownership";
6
6
  import type { OcxProviderConfig, OcxParsedRequest } from "../types";
7
7
  import { createOpenAIChatAdapter } from "./openai-chat";
8
8
  import type { ProviderAdapter, AdapterRequest, IncomingMeta } from "./base";
9
+ import { createAdapterPhysicalSend } from "./physical-send";
10
+ import { SendBudgetExhaustedError } from "../lib/upstream-retry";
9
11
 
10
12
  const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
11
13
  export const MIMO_CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat";
@@ -248,33 +250,46 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap
248
250
  },
249
251
 
250
252
  async fetchResponse(request: AdapterRequest, ctx): Promise<Response> {
251
- const response = await fetch(request.url, {
253
+ const send = createAdapterPhysicalSend(ctx);
254
+ const response = await send({ url: request.url, dispatch: executor => executor(request.url, {
252
255
  method: request.method,
253
256
  redirect: "manual",
254
257
  headers: request.headers as Record<string, string>,
255
258
  body: request.body,
256
259
  signal: ctx?.abortSignal,
257
- });
260
+ }) });
258
261
 
259
262
  // Retry predicate: 401 (expired/invalid JWT) retries ONCE with a fresh token.
260
263
  // 403 is NOT retried — Xiaomi uses it for anti-abuse "Illegal access" and there is
261
264
  // no documented token-expiry signature that would mark a 403 as retryable.
262
265
  if (response.status === 401) {
263
- // Drain the first response body before issuing the retry.
264
- try { await response.body?.cancel(); } catch { /* already consumed */ }
265
- resetMimoJwtCache();
266
- const freshJwt = await getMimoJwt(ctx?.abortSignal);
267
- const retryHeaders = {
268
- ...(request.headers as Record<string, string>),
269
- "Authorization": `Bearer ${freshJwt}`,
270
- };
271
- return fetch(request.url, {
272
- method: request.method,
273
- redirect: "manual",
274
- headers: retryHeaders,
275
- body: request.body,
276
- signal: ctx?.abortSignal,
277
- });
266
+ let retryHeaders = request.headers;
267
+ try {
268
+ return await send({ url: request.url, sendClass: "auth-recovery", recovery: "oauth-401",
269
+ beforeDispatch: async () => {
270
+ // Drain the first response body and refresh the JWT only after admission: a
271
+ // refused replay still returns THIS response to the caller, body intact.
272
+ // Draining comes first within the block because getMimoJwt issues its own
273
+ // network call and may throw, and the 401 body would then never be released.
274
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already consumed */ }
275
+ resetMimoJwtCache();
276
+ const freshJwt = await getMimoJwt(ctx?.abortSignal);
277
+ retryHeaders = {
278
+ ...(request.headers as Record<string, string>),
279
+ "Authorization": `Bearer ${freshJwt}`,
280
+ };
281
+ },
282
+ dispatch: executor => executor(request.url, {
283
+ method: request.method,
284
+ redirect: "manual",
285
+ headers: retryHeaders,
286
+ body: request.body,
287
+ signal: ctx?.abortSignal,
288
+ }) });
289
+ } catch (error) {
290
+ if (error instanceof SendBudgetExhaustedError) return response;
291
+ throw error;
292
+ }
278
293
  }
279
294
 
280
295
  return response;