@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914

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-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic-image-codec.ts +57 -0
  5. package/src/adapters/anthropic-image-normalize.ts +28 -1
  6. package/src/adapters/anthropic.ts +68 -6
  7. package/src/adapters/base.ts +8 -0
  8. package/src/adapters/coding-agent/protocol.ts +41 -16
  9. package/src/adapters/cursor/cursor-errors.ts +1 -1
  10. package/src/adapters/cursor/live-transport.ts +5 -1
  11. package/src/adapters/cursor/native-exec-fs.ts +10 -10
  12. package/src/adapters/cursor/native-exec-network.ts +2 -2
  13. package/src/adapters/cursor/native-exec-shell.ts +13 -12
  14. package/src/adapters/cursor/native-exec.ts +51 -10
  15. package/src/adapters/cursor/policy-error.ts +75 -0
  16. package/src/adapters/cursor/protobuf-request.ts +105 -1
  17. package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
  18. package/src/adapters/devin/live-models.ts +33 -2
  19. package/src/adapters/google-wire-compiler.ts +8 -0
  20. package/src/adapters/google.ts +46 -0
  21. package/src/adapters/input-media-guard.ts +45 -0
  22. package/src/adapters/kiro/adapter.ts +8 -0
  23. package/src/adapters/kiro/payload.ts +28 -6
  24. package/src/adapters/kiro-events.ts +25 -6
  25. package/src/adapters/kiro-images.ts +30 -0
  26. package/src/adapters/kiro-retry.ts +8 -0
  27. package/src/adapters/openai-chat.ts +33 -4
  28. package/src/adapters/openai-responses.ts +26 -0
  29. package/src/adapters/registry.ts +4 -0
  30. package/src/bridge.ts +163 -116
  31. package/src/chat/image-parts.ts +151 -0
  32. package/src/chat/inbound.ts +70 -33
  33. package/src/cli/connect.ts +30 -9
  34. package/src/cli/dispatch.ts +7 -3
  35. package/src/cli/index.ts +3 -0
  36. package/src/cli/runtime-api.ts +25 -0
  37. package/src/cli/status.ts +21 -19
  38. package/src/cli/system-restart-client.ts +25 -0
  39. package/src/clients/config-export.ts +14 -4
  40. package/src/codex/app-server-processes.ts +25 -0
  41. package/src/codex/auth-context.ts +8 -0
  42. package/src/codex/autostart-health.ts +36 -2
  43. package/src/codex/catalog/provider-fetch.ts +41 -0
  44. package/src/codex/catalog-auto-refresh.ts +182 -0
  45. package/src/codex/catalog-refresh-status.ts +93 -0
  46. package/src/codex/history-provider.ts +55 -0
  47. package/src/codex/model-entitlements.ts +78 -0
  48. package/src/codex/native-profile-processes.ts +114 -15
  49. package/src/codex/prompt-text-probe.ts +274 -41
  50. package/src/codex/routing-adoption.ts +189 -0
  51. package/src/codex/routing.ts +520 -48
  52. package/src/codex/runtime.ts +249 -7
  53. package/src/combos/failover.ts +45 -0
  54. package/src/config.ts +124 -4
  55. package/src/generated/compatibility-version.json +110 -74
  56. package/src/generated/model-metadata.ts +1 -0
  57. package/src/lib/request-execution-budget.ts +202 -0
  58. package/src/lib/upstream-retry.ts +95 -8
  59. package/src/lib/workflow-budget.ts +172 -0
  60. package/src/oauth/devin.ts +57 -12
  61. package/src/providers/quota.ts +37 -6
  62. package/src/providers/registry.ts +53 -6
  63. package/src/responses/input-media.ts +65 -0
  64. package/src/responses/parser-content.ts +42 -0
  65. package/src/responses/schema.ts +12 -2
  66. package/src/server/audio-live.ts +1 -2
  67. package/src/server/audio-transcriptions.ts +1 -2
  68. package/src/server/auth-cors.ts +1 -1
  69. package/src/server/background-lifecycle.ts +18 -0
  70. package/src/server/chat-completions.ts +23 -8
  71. package/src/server/chat-native.ts +17 -17
  72. package/src/server/index.ts +24 -0
  73. package/src/server/management/request-history-routes.ts +5 -0
  74. package/src/server/request-log.ts +8 -2
  75. package/src/server/responses/compact.ts +51 -3
  76. package/src/server/responses/core.ts +238 -28
  77. package/src/server/search.ts +7 -9
  78. package/src/types/config.ts +51 -6
  79. package/src/usage/log.ts +37 -0
  80. package/src/vision/eligibility.ts +37 -4
  81. package/src/vision/index.ts +1 -0
  82. package/src/vision/plan.ts +45 -10
  83. package/src/web-search/alpha-search.ts +324 -0
  84. package/src/web-search/index.ts +13 -22
  85. package/src/web-search/passthrough-bridge.ts +195 -22
  86. package/src/web-search/sidecar-providers.ts +22 -0
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-B4VYfZcY.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-DH2PUHqr.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-BBOZWGB6.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.54.0-preview.20260914",
3
+ "version": "2.55.0-preview.20260914",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -105,6 +105,62 @@ let cacheMetadataBytes = 0;
105
105
  let cacheSentinelEntries = 0;
106
106
  let encodeCalls = 0;
107
107
 
108
+ /**
109
+ * Last-EMITTED ladder position per image identity (#4532). The age-tier pyramid in
110
+ * anthropic-image-normalize derives an image's start position from its recency rank
111
+ * within the current request, so appending one newer image shifts every older image's
112
+ * rank by one and can push it across a tier boundary — re-encoding it to different
113
+ * bytes and busting Anthropic's prompt prefix cache for the whole history. Pinning the
114
+ * start position to the image's own identity keeps already-emitted bytes stable across
115
+ * appends. Keys are the encode cache's identity minus the position suffix
116
+ * (`${hash}:${mediaType}`, see processAt). Entry-count cap with LRU eviction: a
117
+ * value is one small number, so a count bound is a byte bound (~4096 * ~50B worst
118
+ * case, far under the app-owned memory budget's headroom).
119
+ */
120
+ const POSITION_STORE_MAX_ENTRIES = 4_096;
121
+ const emittedPositions = new Map<string, number>();
122
+
123
+ function positionKey(b64: string, mediaType: string): string {
124
+ return `${Bun.hash(b64).toString(36)}:${mediaType}`;
125
+ }
126
+
127
+ /**
128
+ * The position this image was last emitted at, if it has been normalized before.
129
+ * Reads refresh recency (insertion-order LRU, same discipline as the encode cache).
130
+ */
131
+ export function recordedEmittedPosition(b64: string, mediaType: string): number | undefined {
132
+ const key = positionKey(b64, mediaType);
133
+ const pos = emittedPositions.get(key);
134
+ if (pos !== undefined) {
135
+ emittedPositions.delete(key);
136
+ emittedPositions.set(key, pos);
137
+ }
138
+ return pos;
139
+ }
140
+
141
+ /**
142
+ * Record the position an image actually ended at. Positions only ever move DOWN the
143
+ * ladder (first-pass tier, aggregate demotion, tierBias) — nothing raises an image
144
+ * back up — so the stored value is monotonically non-decreasing and cannot flap.
145
+ * That monotonicity is what makes identity-pinning safe: a stale entry can only make
146
+ * an image smaller than its fresh tier would, never larger.
147
+ */
148
+ export function recordEmittedPosition(b64: string, mediaType: string, pos: number): void {
149
+ const key = positionKey(b64, mediaType);
150
+ const existing = emittedPositions.get(key);
151
+ if (existing !== undefined) {
152
+ emittedPositions.delete(key);
153
+ pos = Math.max(existing, pos);
154
+ }
155
+ while (emittedPositions.size + 1 > POSITION_STORE_MAX_ENTRIES) {
156
+ const oldest = emittedPositions.keys().next().value;
157
+ if (oldest === undefined) break;
158
+ emittedPositions.delete(oldest);
159
+ }
160
+ emittedPositions.set(key, pos);
161
+ enforceAppOwnedMemoryBudget();
162
+ }
163
+
108
164
  function cacheEntry(key: string, value: CacheValue): CacheEntry {
109
165
  const keyBytes = cacheEncoder.encode(key).byteLength;
110
166
  const valueBytes = typeof value === "string"
@@ -180,6 +236,7 @@ export function getNormalizeStatsForTests(): {
180
236
  }
181
237
  export function resetNormalizeStateForTests(): void {
182
238
  cache.clear();
239
+ emittedPositions.clear();
183
240
  cacheBytes = 0;
184
241
  cacheMetadataBytes = 0;
185
242
  cacheSentinelEntries = 0;
@@ -28,6 +28,7 @@ export { getNormalizeStatsForTests, resetNormalizeStateForTests, setNormalizeCac
28
28
  export { anthropicImageNormalizeRetainedStoreSnapshot, evictOldestAnthropicImageNormalizeForBudget } from "./anthropic-image-codec";
29
29
 
30
30
  import { bunImageEncode, bunImageValidate, processAt, TERMINAL_POS, TIER0_COUNT, TIER1_COUNT } from "./anthropic-image-codec";
31
+ import { recordedEmittedPosition, recordEmittedPosition } from "./anthropic-image-codec";
31
32
  import { IMAGE_NORMALIZE_CONCURRENCY, MAX_INPUT_BASE64_LENGTH, MAX_INPUT_PIXELS } from "./anthropic-image-codec";
32
33
  import type { NormalizeOptions } from "./anthropic-image-codec";
33
34
 
@@ -149,7 +150,23 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options:
149
150
  continue;
150
151
  }
151
152
  const sourceMedia = target.mediaType.toLowerCase();
152
- const pos = initialPosition(newestFirstIndex, bias);
153
+ // #4532: pin the start position to the image's own identity. A never-seen
154
+ // image still gets the age-derived tier; a seen image resumes where it last
155
+ // EMITTED, so appending a newer image cannot re-encode history and bust
156
+ // Anthropic's prompt prefix cache. tierBias (413 retry) applies on top of
157
+ // either base and still clamps to TERMINAL_POS.
158
+ //
159
+ // Every read in this pass sees the store as it was BEFORE this request,
160
+ // because nothing is written until the whole request settles (see the
161
+ // record loop at the end). That is load-bearing, not incidental: an image
162
+ // can appear more than once in one history, and identity keying collapses
163
+ // those occurrences onto one entry. Writing during the pass let the OLDEST
164
+ // occurrence's tier win a race against the newest one and drag it down —
165
+ // 30 copies of a screenshot all landed on the oldest copy's tier instead of
166
+ // the age pyramid. Reading a fixed snapshot gives each occurrence its own
167
+ // age tier on a cold store, which is the pre-#4532 behaviour.
168
+ const recorded = recordedEmittedPosition(b64, sourceMedia);
169
+ const pos = Math.min((recorded ?? initialPosition(newestFirstIndex, 0)) + Math.max(0, bias), TERMINAL_POS);
153
170
  const result = await processAt(b64, pos, sourceMedia, encode, validate);
154
171
  if (result.kind === "failed") {
155
172
  target.drop(UNDECODABLE_TEXT);
@@ -218,6 +235,16 @@ export async function normalizeImageTargets(targets: NormalizeTarget[], options:
218
235
  entry.done = result.pos >= TERMINAL_POS;
219
236
  }
220
237
 
238
+ // #4532: commit the positions these images actually went out at, now that the
239
+ // first pass and the aggregate demotion loop have both settled. Written here
240
+ // rather than inline so every read above saw one consistent pre-request
241
+ // snapshot. `recordEmittedPosition` keeps the deeper of the stored and the new
242
+ // position, so a repeated image converges on the most-demoted tier it was ever
243
+ // emitted at and never moves back up.
244
+ for (const entry of entries) {
245
+ if (entry) recordEmittedPosition(entry.sourceB64, entry.sourceMedia, entry.pos);
246
+ }
247
+
221
248
  // Terminal overflow (050 audit round 1, blocker 3): with no downstream guard, drop
222
249
  // OLDEST targets until the sum fits.
223
250
  if (overflowAction === "drop") {
@@ -267,6 +267,33 @@ export function formatAnthropicErrorBody(status: number, _headers: Headers, payl
267
267
  return redactSecretString(detail).slice(0, 400);
268
268
  }
269
269
 
270
+ function isAnthropicContentFilterStopReason(
271
+ stopReason: string | undefined,
272
+ ): stopReason is "refusal" | "content_filter" {
273
+ return stopReason === "refusal" || stopReason === "content_filter";
274
+ }
275
+
276
+ /**
277
+ * Anthropic `refusal` / `content_filter` is a permanent sampling decision, not a disconnect.
278
+ * Emitting `done` with that stopReason used to surface as `response.incomplete` without
279
+ * `retryable`, which Codex treats as a dropped stream and retries five times (#4312).
280
+ * The explicit incomplete event is what the bridge already forwards into
281
+ * `incomplete_details.retryable`. Usage is preserved: a filtered turn still consumed tokens.
282
+ * `max_tokens` stays a `done` so the client can continue from a legitimate truncation.
283
+ */
284
+ function anthropicContentFilterIncomplete(
285
+ stopReason: string,
286
+ usage: OcxUsage | undefined,
287
+ ): Extract<AdapterEvent, { type: "incomplete" }> {
288
+ return {
289
+ type: "incomplete",
290
+ reason: "content_filter",
291
+ retryable: false,
292
+ message: `upstream ended the turn with stop_reason "${stopReason}"`,
293
+ usage,
294
+ };
295
+ }
296
+
270
297
  function isAnthropicRecord(value: unknown): value is Record<string, unknown> {
271
298
  return value !== null && typeof value === "object" && !Array.isArray(value);
272
299
  }
@@ -1019,6 +1046,22 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1019
1046
  else if (tc === "required") body.tool_choice = { type: "any" };
1020
1047
  else if (isAllowedToolChoice(tc)) body.tool_choice = { type: tc.mode === "required" ? "any" : "auto" };
1021
1048
  else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(resolveToolChoiceWireName(parsed.context.tools, tc.name)) };
1049
+ } else if (tools && parsed.options.parallelToolCalls === false) {
1050
+ // The caller asked for one tool call at a time but sent no explicit choice.
1051
+ // Anthropic carries that intent INSIDE tool_choice, so the implicit default
1052
+ // has to be stated before the flag has somewhere to live.
1053
+ body.tool_choice = { type: "auto" };
1054
+ }
1055
+ // disable_parallel_tool_use is nested in tool_choice and caps the model at one
1056
+ // tool call for auto/any/tool. Under type "none" tool use is already off, so the
1057
+ // flag is irrelevant there, and with no tools on the wire no tool_choice exists.
1058
+ // This constrains the model's OUTPUT, not execution order: sequential tool use is
1059
+ // enforced by the caller returning each tool_result before the next request.
1060
+ const settledToolChoice = body.tool_choice as { type?: string } | undefined;
1061
+ if (parsed.options.parallelToolCalls === false
1062
+ && settledToolChoice !== undefined
1063
+ && settledToolChoice.type !== "none") {
1064
+ body.tool_choice = { ...settledToolChoice, disable_parallel_tool_use: true };
1022
1065
  }
1023
1066
 
1024
1067
  const url = anthropicMessagesUrl(provider.baseUrl);
@@ -1094,6 +1137,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1094
1137
  };
1095
1138
  return;
1096
1139
  }
1140
+ // Refusal / content_filter must not look like a dropped stream. `done` with that
1141
+ // stopReason becomes `response.incomplete` without `retryable`, and Codex retries
1142
+ // the same refusal five times (#4312).
1143
+ if (isAnthropicContentFilterStopReason(pendingStopReason)) {
1144
+ yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage));
1145
+ return;
1146
+ }
1097
1147
  yield {
1098
1148
  type: "done",
1099
1149
  usage: usageFromAnthropic(pendingUsage),
@@ -1268,16 +1318,19 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1268
1318
  };
1269
1319
  return;
1270
1320
  }
1271
- const stopReason = pendingStopReason === "max_tokens"
1272
- ? "max_tokens"
1273
- : pendingStopReason === "refusal" || pendingStopReason === "content_filter"
1274
- ? "content_filter"
1275
- : pendingStopReason;
1321
+ // Same rule as emitDone: refusal / content_filter is a permanent decision, not a
1322
+ // disconnect. This branch bypasses emitDone, so the check has to be repeated here
1323
+ // or the EOF route still emits `done` and Codex retries the refusal (#4312).
1324
+ if (isAnthropicContentFilterStopReason(pendingStopReason)) {
1325
+ emittedDone = true;
1326
+ yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage));
1327
+ return;
1328
+ }
1276
1329
  emittedDone = true;
1277
1330
  yield {
1278
1331
  type: "done",
1279
1332
  usage: usageFromAnthropic(pendingUsage),
1280
- ...(stopReason ? { stopReason } : {}),
1333
+ ...(pendingStopReason ? { stopReason: pendingStopReason } : {}),
1281
1334
  };
1282
1335
  } else if (provider.anthropicEofTolerance === true) {
1283
1336
  // AgentRouter-style compatibility profile (#658): the upstream can close the stream
@@ -1393,6 +1446,15 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1393
1446
  retainTranslatedEventBatch(events, budget);
1394
1447
  return events;
1395
1448
  }
1449
+ // Same rule as the streaming terminals: a refusal is explicit and non-retryable.
1450
+ // Leaving it as `done` hides `retryable: false` and Codex retries the filtered
1451
+ // turn as if the stream dropped (#4312). Partial content above is already in
1452
+ // `events`; the incomplete event carries usage the same way `done` did.
1453
+ if (isAnthropicContentFilterStopReason(stopReason)) {
1454
+ events.push(anthropicContentFilterIncomplete(stopReason, usageFromAnthropic(usage)));
1455
+ retainTranslatedEventBatch(events, budget);
1456
+ return events;
1457
+ }
1396
1458
  events.push({
1397
1459
  type: "done",
1398
1460
  usage: usageFromAnthropic(usage),
@@ -1,5 +1,6 @@
1
1
  import type { AdapterEvent, OcxParsedRequest } from "../types";
2
2
  import type { TranslatorBudget } from "../lib/translator-budget";
3
+ import type { RequestExecutionBudget } from "../lib/request-execution-budget";
3
4
  import type { AdapterTierMetadata } from "../providers/fastwire";
4
5
 
5
6
  /** Metadata about the caller's incoming request, for auth-forwarding adapters. */
@@ -139,6 +140,13 @@ export interface AdapterFetchContext {
139
140
  stream?: boolean;
140
141
  /** Custom fetch executor to use for physical upstream network requests (defaults to globalThis.fetch). */
141
142
  executor?: typeof globalThis.fetch;
143
+ /**
144
+ * The logical request's send budget (#4546). Optional and unlimited when absent, so an
145
+ * adapter unit test that calls a transport context-free keeps its own retry shape. An
146
+ * adapter that retries internally must admit EVERY physical send against it: counting one
147
+ * adapter entry as one send is how a nested 3x3 ladder stayed invisible to a request cap.
148
+ */
149
+ sendBudget?: RequestExecutionBudget;
142
150
  }
143
151
 
144
152
  /**
@@ -409,7 +409,24 @@ export function buildConversationInput(parsed: OcxParsedRequest): string[] {
409
409
  const historyMessages = nonDev.slice(0, -1);
410
410
  const currentMessage = nonDev[nonDev.length - 1]!;
411
411
 
412
- const imageBlocks: WireContentPart[] = [];
412
+ // History images are collected BEFORE the current message's so the attached blocks
413
+ // follow conversation order. The projected prose says "Prior conversation context"
414
+ // then "Current user request", so emitting current-turn images first contradicted
415
+ // the text the model reads alongside them.
416
+ const historyImageBlocks: WireContentPart[] = [];
417
+ for (const msg of historyMessages) {
418
+ if (!Array.isArray(msg.content)) continue;
419
+ // Tool results carry images too — a screenshot returned by a tool was previously
420
+ // flattened to the literal text "[image]" and the carrier discarded.
421
+ if (msg.role !== "user" && msg.role !== "toolResult") continue;
422
+ for (const part of msg.content) {
423
+ if (part.type !== "image") continue;
424
+ const img = imagePart(part.imageUrl);
425
+ if (img) historyImageBlocks.push(img);
426
+ }
427
+ }
428
+
429
+ const currentImageBlocks: WireContentPart[] = [];
413
430
  let currentRequestText = "";
414
431
 
415
432
  if (currentMessage.role === "user") {
@@ -421,7 +438,8 @@ export function buildConversationInput(parsed: OcxParsedRequest): string[] {
421
438
  if (part.type === "text") textParts.push(part.text);
422
439
  else if (part.type === "image") {
423
440
  const image = imagePart(part.imageUrl);
424
- if (image) imageBlocks.push(image);
441
+ if (image) currentImageBlocks.push(image);
442
+ else textParts.push("[image omitted: unsupported reference]");
425
443
  } else {
426
444
  textParts.push("[video]");
427
445
  }
@@ -429,26 +447,33 @@ export function buildConversationInput(parsed: OcxParsedRequest): string[] {
429
447
  currentRequestText = textParts.join("\n");
430
448
  }
431
449
  } else if (currentMessage.role === "toolResult") {
432
- const text = typeof currentMessage.content === "string"
433
- ? currentMessage.content
434
- : currentMessage.content.map(p => (p.type === "text" ? p.text : "[image]")).join("");
450
+ let text: string;
451
+ if (typeof currentMessage.content === "string") {
452
+ text = currentMessage.content;
453
+ } else {
454
+ const segments: string[] = [];
455
+ for (const part of currentMessage.content) {
456
+ if (part.type === "text") { segments.push(part.text); continue; }
457
+ if (part.type === "image") {
458
+ // Carry the real image instead of flattening it to a marker. The provenance
459
+ // note stays so the prose still reads coherently and the model can tell which
460
+ // attachment the tool produced; the bytes travel as an image block, never as text.
461
+ const image = imagePart(part.imageUrl);
462
+ if (image) { currentImageBlocks.push(image); segments.push("[image attached below]"); }
463
+ else segments.push("[image omitted: unsupported reference]");
464
+ continue;
465
+ }
466
+ segments.push("[video]");
467
+ }
468
+ text = segments.join("");
469
+ }
435
470
  const status = currentMessage.isError ? " (error)" : "";
436
471
  currentRequestText = `TOOL RESULT (call_id: ${currentMessage.toolCallId})${status}:\n${text}\n\nPlease proceed based on the above tool result.`;
437
472
  } else {
438
473
  currentRequestText = formatMessageForHistory(currentMessage);
439
474
  }
440
475
 
441
- // Also collect any images from history messages so multimodal attachments are never dropped:
442
- for (const msg of historyMessages) {
443
- if (msg.role === "user" && Array.isArray(msg.content)) {
444
- for (const part of msg.content) {
445
- if (part.type === "image") {
446
- const img = imagePart(part.imageUrl);
447
- if (img) imageBlocks.push(img);
448
- }
449
- }
450
- }
451
- }
476
+ const imageBlocks: WireContentPart[] = [...historyImageBlocks, ...currentImageBlocks];
452
477
 
453
478
  let historyText = historyMessages.map(formatMessageForHistory).filter(Boolean).join("\n\n");
454
479
  if (historyText.length > MAX_PROJECTED_HISTORY_CHARS) {
@@ -298,7 +298,7 @@ export function classifyCursorError(message: string, sizeContext?: CursorSizeCon
298
298
  ) return "Cursor authentication failed";
299
299
 
300
300
  // gRPC FAILED_PRECONDITION is deterministic and non-retryable (unlike UNAVAILABLE):
301
- // the backend rejected the call because the account/plan state does not allow it —
301
+ // the backend rejected the call because account/plan or policy-consent state does not allow it —
302
302
  // seen live when a plan-gated model (e.g. claude-fable-5) runs on a plan without it.
303
303
  // Leaving it as "Cursor upstream error" (502) made clients retry it as overload.
304
304
  //
@@ -53,9 +53,11 @@ import {
53
53
  } from "./gen/agent_pb";
54
54
  import { debugProviderDiagnostic } from "../../lib/debug";
55
55
  import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
56
+ import { cursorPolicyErrorExplanation } from "./policy-error";
56
57
  import { mcpArgsFromToolCall } from "./protobuf-events";
57
58
  import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
58
59
  import {
60
+ cursorNativeExecRedirectHint,
59
61
  handleCursorNativeExec,
60
62
  handleCursorNativeKv,
61
63
  releaseCursorBlobRequestScope,
@@ -209,7 +211,8 @@ export function parseConnectEndStreamError(payload: Uint8Array): Error | null {
209
211
  try {
210
212
  const parsed = JSON.parse(new TextDecoder().decode(payload)) as { error?: { code?: string; message?: string } };
211
213
  if (parsed?.error) {
212
- return new Error(`Cursor Connect error ${parsed.error.code ?? "unknown"}: ${parsed.error.message ?? "Unknown error"}`);
214
+ const explanation = cursorPolicyErrorExplanation(parsed.error);
215
+ return new Error(`Cursor Connect error ${parsed.error.code ?? "unknown"}: ${explanation ?? parsed.error.message ?? "Unknown error"}`);
213
216
  }
214
217
  return null;
215
218
  } catch {
@@ -697,6 +700,7 @@ class LiveCursorTransport implements CursorTransport {
697
700
  clientToolDefs,
698
701
  rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice),
699
702
  structuredEditAvailable: syntheticStructuredEditToolNames.size > 0,
703
+ nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools, this.execContext.mcpToolDefs ?? []),
700
704
  };
701
705
  const toolSchemas = new Map<string, unknown>();
702
706
  const cursorToolNameMap = new Map<string, string>();
@@ -49,11 +49,11 @@ function codexNativeMutationRefusal(operation: "write" | "delete", structuredEdi
49
49
  const NATIVE_LOCAL_EXEC_DISABLED =
50
50
  "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call.";
51
51
 
52
- export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
52
+ export function rejectReadExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
53
53
  if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec");
54
54
  const path = resolve(execMsg.message.value.path);
55
55
  return execBytes(execMsg, "readResult", create(ReadResultSchema, {
56
- result: { case: "error", value: create(ReadErrorSchema, { path, error: NATIVE_LOCAL_EXEC_DISABLED }) },
56
+ result: { case: "error", value: create(ReadErrorSchema, { path, error: hint ?? NATIVE_LOCAL_EXEC_DISABLED }) },
57
57
  }));
58
58
  }
59
59
 
@@ -98,13 +98,13 @@ export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structu
98
98
  }));
99
99
  }
100
100
 
101
- export function rejectWriteExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
101
+ export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
102
102
  if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec");
103
103
  const path = resolve(execMsg.message.value.path);
104
104
  return execBytes(execMsg, "writeResult", create(WriteResultSchema, {
105
105
  result: {
106
106
  case: "rejected",
107
- value: create(WriteRejectedSchema, { path, reason: `${NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }),
107
+ value: create(WriteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }),
108
108
  },
109
109
  }));
110
110
  }
@@ -147,13 +147,13 @@ export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, struct
147
147
  }));
148
148
  }
149
149
 
150
- export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
150
+ export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
151
151
  if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec");
152
152
  const path = resolve(execMsg.message.value.path);
153
153
  return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, {
154
154
  result: {
155
155
  case: "rejected",
156
- value: create(DeleteRejectedSchema, { path, reason: `${NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }),
156
+ value: create(DeleteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }),
157
157
  },
158
158
  }));
159
159
  }
@@ -188,11 +188,11 @@ export function deleteExec(execMsg: ExecServerMessage): Uint8Array {
188
188
  }
189
189
  }
190
190
 
191
- export function rejectLsExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
191
+ export function rejectLsExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
192
192
  if (execMsg.message.case !== "lsArgs") throw new Error("invalid ls exec");
193
193
  const path = resolve(execMsg.message.value.path);
194
194
  return execBytes(execMsg, "lsResult", create(LsResultSchema, {
195
- result: { case: "error", value: create(LsErrorSchema, { path, error: NATIVE_LOCAL_EXEC_DISABLED }) },
195
+ result: { case: "error", value: create(LsErrorSchema, { path, error: hint ?? NATIVE_LOCAL_EXEC_DISABLED }) },
196
196
  }));
197
197
  }
198
198
 
@@ -256,8 +256,8 @@ function grepError(execMsg: ExecServerMessage, error: string): Uint8Array {
256
256
  }));
257
257
  }
258
258
 
259
- export function rejectGrepExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
260
- return grepError(execMsg, NATIVE_LOCAL_EXEC_DISABLED);
259
+ export function rejectGrepExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
260
+ return grepError(execMsg, hint ?? NATIVE_LOCAL_EXEC_DISABLED);
261
261
  }
262
262
 
263
263
  export function grepExec(execMsg: ExecServerMessage): Uint8Array {
@@ -9,11 +9,11 @@ export interface CursorNativeNetworkDeps {
9
9
  const NATIVE_FETCH_DISABLED =
10
10
  "Re-issue this fetch NOW through the catalog shell tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do NOT narrate this redirect or comment on tool availability — just make the bridge call.";
11
11
 
12
- export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
12
+ export function rejectFetchExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
13
13
  if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec");
14
14
  const args = execMsg.message.value;
15
15
  return execBytes(execMsg, "fetchResult", create(FetchResultSchema, {
16
- result: { case: "error", value: create(FetchErrorSchema, { url: args.url, error: NATIVE_FETCH_DISABLED }) },
16
+ result: { case: "error", value: create(FetchErrorSchema, { url: args.url, error: hint ?? NATIVE_FETCH_DISABLED }) },
17
17
  }));
18
18
  }
19
19
 
@@ -82,7 +82,8 @@ let unresolvedKills = 0;
82
82
  let killFailures = 0;
83
83
 
84
84
  /** Rejection text when Cursor-native shell is denied by policy (issue #604). */
85
- export function nativeShellDisabledMessage(): string {
85
+ export function nativeShellDisabledMessage(hint?: string): string {
86
+ if (hint) return hint;
86
87
  // Do not insist on "the same command" — that steers models into replaying bash/CMD
87
88
  // idioms through the Codex bridge on Windows PowerShell 5.1 and looping (#604).
88
89
  // Keep this host-shell-neutral: OpenCodex may run on a different OS than the Codex
@@ -98,7 +99,7 @@ export function nativeShellDisabledMessage(): string {
98
99
  );
99
100
  }
100
101
 
101
- function rejectedShellResult(command: string, cwd: string, started: number) {
102
+ function rejectedShellResult(command: string, cwd: string, started: number, hint?: string) {
102
103
  return create(ShellResultSchema, {
103
104
  result: {
104
105
  case: "failure",
@@ -108,7 +109,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) {
108
109
  exitCode: 1,
109
110
  signal: "",
110
111
  stdout: "",
111
- stderr: nativeShellDisabledMessage(),
112
+ stderr: nativeShellDisabledMessage(hint),
112
113
  executionTime: Date.now() - started,
113
114
  aborted: true,
114
115
  }),
@@ -116,10 +117,10 @@ function rejectedShellResult(command: string, cwd: string, started: number) {
116
117
  });
117
118
  }
118
119
 
119
- export function rejectShellExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
120
+ export function rejectShellExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
120
121
  if (execMsg.message.case !== "shellArgs") throw new Error("invalid shell exec");
121
122
  const args = execMsg.message.value;
122
- return execBytes(execMsg, "shellResult", rejectedShellResult(args.command, resolve(args.workingDirectory || process.cwd()), Date.now()));
123
+ return execBytes(execMsg, "shellResult", rejectedShellResult(args.command, resolve(args.workingDirectory || process.cwd()), Date.now(), hint));
123
124
  }
124
125
 
125
126
  export function shellExec(execMsg: ExecServerMessage): Uint8Array {
@@ -157,7 +158,7 @@ export function shellExec(execMsg: ExecServerMessage): Uint8Array {
157
158
  }));
158
159
  }
159
160
 
160
- export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint8Array[] {
161
+ export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array[] {
161
162
  if (execMsg.message.case !== "shellStreamArgs") throw new Error("invalid shell stream exec");
162
163
  const args = execMsg.message.value;
163
164
  const cwd = resolve(args.workingDirectory || process.cwd());
@@ -167,12 +168,12 @@ export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint
167
168
  event: { case: "start", value: create(ShellStreamStartSchema, { sandboxPolicy: args.requestedSandboxPolicy }) },
168
169
  })),
169
170
  execBytes(execMsg, "shellStream", create(ShellStreamSchema, {
170
- event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage() }) },
171
+ event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage(hint) }) },
171
172
  })),
172
173
  execBytes(execMsg, "shellStream", create(ShellStreamSchema, {
173
174
  event: { case: "exit", value: create(ShellStreamExitSchema, { code: 1, cwd, aborted: true }) },
174
175
  })),
175
- execBytes(execMsg, "shellResult", rejectedShellResult(args.command, cwd, started)),
176
+ execBytes(execMsg, "shellResult", rejectedShellResult(args.command, cwd, started, hint)),
176
177
  execStreamCloseBytes(execMsg),
177
178
  ];
178
179
  }
@@ -263,12 +264,12 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise<Uint8
263
264
  return replies;
264
265
  }
265
266
 
266
- export function rejectBackgroundShellSpawnExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
267
+ export function rejectBackgroundShellSpawnExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
267
268
  if (execMsg.message.case !== "backgroundShellSpawnArgs") throw new Error("invalid background shell exec");
268
269
  const args = execMsg.message.value;
269
270
  const cwd = resolve(args.workingDirectory || process.cwd());
270
271
  return execBytes(execMsg, "backgroundShellSpawnResult", create(BackgroundShellSpawnResultSchema, {
271
- result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: nativeShellDisabledMessage() }) },
272
+ result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: nativeShellDisabledMessage(hint) }) },
272
273
  }));
273
274
  }
274
275
 
@@ -520,10 +521,10 @@ export function backgroundShellSpawnExec(execMsg: ExecServerMessage, sessionId:
520
521
  }
521
522
  }
522
523
 
523
- export function rejectWriteShellStdinExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
524
+ export function rejectWriteShellStdinExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array {
524
525
  if (execMsg.message.case !== "writeShellStdinArgs") throw new Error("invalid shell stdin exec");
525
526
  return execBytes(execMsg, "writeShellStdinResult", create(WriteShellStdinResultSchema, {
526
- result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: nativeShellDisabledMessage() }) },
527
+ result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: nativeShellDisabledMessage(hint) }) },
527
528
  }));
528
529
  }
529
530