@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.
- package/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +57 -0
- package/src/adapters/anthropic-image-normalize.ts +28 -1
- package/src/adapters/anthropic.ts +68 -6
- package/src/adapters/base.ts +8 -0
- package/src/adapters/coding-agent/protocol.ts +41 -16
- package/src/adapters/cursor/cursor-errors.ts +1 -1
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/native-exec-fs.ts +10 -10
- package/src/adapters/cursor/native-exec-network.ts +2 -2
- package/src/adapters/cursor/native-exec-shell.ts +13 -12
- package/src/adapters/cursor/native-exec.ts +51 -10
- package/src/adapters/cursor/policy-error.ts +75 -0
- package/src/adapters/cursor/protobuf-request.ts +105 -1
- package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
- package/src/adapters/devin/live-models.ts +33 -2
- package/src/adapters/google-wire-compiler.ts +8 -0
- package/src/adapters/google.ts +46 -0
- package/src/adapters/input-media-guard.ts +45 -0
- package/src/adapters/kiro/adapter.ts +8 -0
- package/src/adapters/kiro/payload.ts +28 -6
- package/src/adapters/kiro-events.ts +25 -6
- package/src/adapters/kiro-images.ts +30 -0
- package/src/adapters/kiro-retry.ts +8 -0
- package/src/adapters/openai-chat.ts +33 -4
- package/src/adapters/openai-responses.ts +26 -0
- package/src/adapters/registry.ts +4 -0
- package/src/bridge.ts +163 -116
- package/src/chat/image-parts.ts +151 -0
- package/src/chat/inbound.ts +70 -33
- package/src/cli/connect.ts +30 -9
- package/src/cli/dispatch.ts +7 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/runtime-api.ts +25 -0
- package/src/cli/status.ts +21 -19
- package/src/cli/system-restart-client.ts +25 -0
- package/src/clients/config-export.ts +14 -4
- package/src/codex/app-server-processes.ts +25 -0
- package/src/codex/auth-context.ts +8 -0
- package/src/codex/autostart-health.ts +36 -2
- package/src/codex/catalog/provider-fetch.ts +41 -0
- package/src/codex/catalog-auto-refresh.ts +182 -0
- package/src/codex/catalog-refresh-status.ts +93 -0
- package/src/codex/history-provider.ts +55 -0
- package/src/codex/model-entitlements.ts +78 -0
- package/src/codex/native-profile-processes.ts +114 -15
- package/src/codex/prompt-text-probe.ts +274 -41
- package/src/codex/routing-adoption.ts +189 -0
- package/src/codex/routing.ts +520 -48
- package/src/codex/runtime.ts +249 -7
- package/src/combos/failover.ts +45 -0
- package/src/config.ts +124 -4
- package/src/generated/compatibility-version.json +110 -74
- package/src/generated/model-metadata.ts +1 -0
- package/src/lib/request-execution-budget.ts +202 -0
- package/src/lib/upstream-retry.ts +95 -8
- package/src/lib/workflow-budget.ts +172 -0
- package/src/oauth/devin.ts +57 -12
- package/src/providers/quota.ts +37 -6
- package/src/providers/registry.ts +53 -6
- package/src/responses/input-media.ts +65 -0
- package/src/responses/parser-content.ts +42 -0
- package/src/responses/schema.ts +12 -2
- package/src/server/audio-live.ts +1 -2
- package/src/server/audio-transcriptions.ts +1 -2
- package/src/server/auth-cors.ts +1 -1
- package/src/server/background-lifecycle.ts +18 -0
- package/src/server/chat-completions.ts +23 -8
- package/src/server/chat-native.ts +17 -17
- package/src/server/index.ts +24 -0
- package/src/server/management/request-history-routes.ts +5 -0
- package/src/server/request-log.ts +8 -2
- package/src/server/responses/compact.ts +51 -3
- package/src/server/responses/core.ts +238 -28
- package/src/server/search.ts +7 -9
- package/src/types/config.ts +51 -6
- package/src/usage/log.ts +37 -0
- package/src/vision/eligibility.ts +37 -4
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +45 -10
- package/src/web-search/alpha-search.ts +324 -0
- package/src/web-search/index.ts +13 -22
- package/src/web-search/passthrough-bridge.ts +195 -22
- package/src/web-search/sidecar-providers.ts +22 -0
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
...(
|
|
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),
|
package/src/adapters/base.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|