@bitkyc08/opencodex 2.24.1 → 2.25.0-preview.20260818
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-C3FiAveG.js → index-TFd4xi1L.js} +8 -8
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +42 -0
- package/src/adapters/client-fingerprint.ts +9 -5
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +17 -0
- package/src/adapters/cursor/cursor-errors.ts +49 -0
- package/src/adapters/cursor/live-models.ts +36 -2
- package/src/adapters/cursor/live-transport.ts +55 -4
- package/src/adapters/cursor/native-exec.ts +9 -0
- package/src/adapters/cursor/protobuf-request.ts +160 -9
- package/src/adapters/cursor/request-builder.ts +9 -1
- package/src/adapters/cursor/tool-definitions.ts +7 -2
- package/src/adapters/google-antigravity-wire.ts +1 -1
- package/src/adapters/google.ts +30 -12
- package/src/adapters/openai-responses-url.ts +5 -3
- package/src/adapters/registry.ts +3 -1
- package/src/adapters/tool-catalog-nudge.ts +76 -9
- package/src/bridge.ts +53 -9
- package/src/claude/context-windows.ts +2 -2
- package/src/claude/desktop-3p.ts +6 -6
- package/src/claude/model-info.ts +2 -2
- package/src/cli/claude-desktop.ts +2 -3
- package/src/codex/app-server-processes.ts +69 -35
- package/src/codex/catalog/metadata.ts +29 -10
- package/src/codex/catalog/provider-fetch.ts +21 -11
- package/src/codex/catalog.ts +1 -1
- package/src/codex/injected-marker.ts +9 -3
- package/src/codex/user-identity.ts +88 -6
- package/src/config.ts +1 -0
- package/src/generated/compatibility-version.json +61 -53
- package/src/grok/sync.ts +2 -4
- package/src/lab/projection/rebuild.ts +36 -18
- package/src/lib/windows-elevation.ts +18 -3
- package/src/lib/windows-secret-acl.ts +49 -19
- package/src/oauth/google-antigravity.ts +7 -2
- package/src/providers/antigravity-models.ts +126 -17
- package/src/providers/derive.ts +11 -1
- package/src/responses/parser.ts +4 -0
- package/src/responses/reasoning-replay-cache.ts +16 -1
- package/src/responses/thought-signature-replay.ts +17 -1
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +2 -10
- package/src/routing/capability.ts +5 -6
- package/src/server/index.ts +3 -4
- package/src/server/management/agent-settings-routes.ts +5 -5
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/context.ts +2 -0
- package/src/server/management/native-integration-routes.ts +3 -3
- package/src/server/management/provider-routes.ts +22 -0
- package/src/server/management/shared.ts +4 -4
- package/src/server/management-api.ts +2 -2
- package/src/server/request-log.ts +11 -3
- package/src/server/responses/core.ts +4 -1
- package/src/server/responses/input-admission.ts +13 -10
- package/src/server/system-env.ts +3 -3
- package/src/types.ts +13 -1
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-TFd4xi1L.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-DQsMZzI5.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.25.0-preview.20260818",
|
|
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",
|
|
@@ -989,6 +989,18 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
989
989
|
const emitDone = function* (): Generator<AdapterEvent> {
|
|
990
990
|
if (emittedDone) return;
|
|
991
991
|
emittedDone = true;
|
|
992
|
+
// An `error` stop reason is a failed generation, not a stop. Forwarding it as `done`
|
|
993
|
+
// lets the turn report success and install replacement history on a compaction turn.
|
|
994
|
+
if (pendingStopReason === "error") {
|
|
995
|
+
yield {
|
|
996
|
+
type: "error",
|
|
997
|
+
message: "upstream ended the turn with stop_reason \"error\"",
|
|
998
|
+
status: 502,
|
|
999
|
+
errorType: "upstream_error",
|
|
1000
|
+
usage: usageFromAnthropic(pendingUsage),
|
|
1001
|
+
};
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
992
1004
|
yield {
|
|
993
1005
|
type: "done",
|
|
994
1006
|
usage: usageFromAnthropic(pendingUsage),
|
|
@@ -1143,6 +1155,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1143
1155
|
if (!emittedDone) {
|
|
1144
1156
|
// Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
|
|
1145
1157
|
if (pendingStopReason !== undefined) {
|
|
1158
|
+
// Same rule as emitDone: an `error` stop reason is a failed generation, not a stop.
|
|
1159
|
+
// This branch bypasses emitDone entirely (it exists for providers that close after
|
|
1160
|
+
// message_delta without message_stop), so the check has to be repeated here or the
|
|
1161
|
+
// EOF route silently reports success.
|
|
1162
|
+
if (pendingStopReason === "error") {
|
|
1163
|
+
emittedDone = true;
|
|
1164
|
+
yield {
|
|
1165
|
+
type: "error",
|
|
1166
|
+
message: "upstream ended the turn with stop_reason \"error\"",
|
|
1167
|
+
status: 502,
|
|
1168
|
+
errorType: "upstream_error",
|
|
1169
|
+
usage: usageFromAnthropic(pendingUsage),
|
|
1170
|
+
};
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1146
1173
|
const stopReason = pendingStopReason === "max_tokens"
|
|
1147
1174
|
? "max_tokens"
|
|
1148
1175
|
: pendingStopReason === "refusal" || pendingStopReason === "content_filter"
|
|
@@ -1210,6 +1237,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
|
|
|
1210
1237
|
}
|
|
1211
1238
|
const usage = json.usage as Record<string, number> | undefined;
|
|
1212
1239
|
const stopReason = typeof json.stop_reason === "string" ? json.stop_reason : undefined;
|
|
1240
|
+
// An Anthropic-compatible upstream can forward an `error` stop reason verbatim. As a
|
|
1241
|
+
// `done` it reads as a clean completion, so the turn reports success and — on a compaction
|
|
1242
|
+
// turn — installs its partial summary as replacement history (#422). Usage is preserved:
|
|
1243
|
+
// a failed turn still consumed tokens.
|
|
1244
|
+
if (stopReason === "error") {
|
|
1245
|
+
events.push({
|
|
1246
|
+
type: "error",
|
|
1247
|
+
message: "upstream ended the turn with stop_reason \"error\"",
|
|
1248
|
+
status: 502,
|
|
1249
|
+
errorType: "upstream_error",
|
|
1250
|
+
usage: usageFromAnthropic(usage),
|
|
1251
|
+
});
|
|
1252
|
+
retainTranslatedEventBatch(events, budget);
|
|
1253
|
+
return events;
|
|
1254
|
+
}
|
|
1213
1255
|
events.push({
|
|
1214
1256
|
type: "done",
|
|
1215
1257
|
usage: usageFromAnthropic(usage),
|
|
@@ -49,15 +49,19 @@ const ANTIGRAVITY_IDE_PLATFORM = "windows/amd64";
|
|
|
49
49
|
export const ANTIGRAVITY_GOOG_API_CLIENT_UA = "google-api-nodejs-client/10.3.0";
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
53
|
-
* `antigravity/ide
|
|
52
|
+
* Real Antigravity IDE User-Agent format, decompiled from 2.5.5 Go LS (`setHeaders` @ `0x1018fbe00`):
|
|
53
|
+
* `antigravity/ide/${version} (os_type=${osType}; arch=${arch}; aidev_client; auth_method=oauth)`
|
|
54
54
|
*
|
|
55
|
-
*
|
|
55
|
+
* Token ordering from decompiled binary: `os_type` -> `arch` -> `aidev_client` -> `auth_method=oauth`.
|
|
56
|
+
*
|
|
57
|
+
* Must be the IDE client family (`antigravity/ide/...`): Cloud Code Assist backend gates
|
|
56
58
|
* newer agent models (e.g. `gemini-3.7-flash`) by User-Agent and answers 404 NOT_FOUND to
|
|
57
59
|
* CLI-shaped UAs even with a valid OAuth token. Only `antigravity/ide/<ver>` unlocks them.
|
|
58
60
|
* A `GOOGLE_ANTIGRAVITY_USER_AGENT` override (set by the caller) takes precedence upstream.
|
|
59
61
|
*/
|
|
60
|
-
export function antigravityUserAgent(version = ANTIGRAVITY_IDE_VERSION): string {
|
|
62
|
+
export function antigravityUserAgent(version = ANTIGRAVITY_IDE_VERSION, authMethod = "oauth"): string {
|
|
63
|
+
const ov = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT?.trim();
|
|
64
|
+
if (ov) return ov;
|
|
61
65
|
const [osType, arch] = ANTIGRAVITY_IDE_PLATFORM.split("/");
|
|
62
|
-
return `antigravity/ide/${version} (
|
|
66
|
+
return `antigravity/ide/${version} (os_type=${osType}; arch=${arch}; ${ANTIGRAVITY_IDE_CLIENT_NAME}; auth_method=${authMethod})`;
|
|
63
67
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ProviderAdapter } from "./base";
|
|
2
|
+
|
|
3
|
+
const CLINE_PASS_DEEPSEEK_V4_MODELS = new Set([
|
|
4
|
+
"cline-pass/deepseek-v4-flash",
|
|
5
|
+
"cline-pass/deepseek-v4-pro",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isClinePassDeepSeekV4Model(modelId: string): boolean {
|
|
13
|
+
return CLINE_PASS_DEEPSEEK_V4_MODELS.has(modelId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* DeepSeek V4 can copy historical pre-tool narration back into the next turn and
|
|
18
|
+
* eventually degenerate into text-only "I'll call the tool" loops. For the two
|
|
19
|
+
* affected ClinePass models, replay historical assistant tool turns as the
|
|
20
|
+
* structured call only. Normal assistant messages, tool results, and separate
|
|
21
|
+
* reasoning metadata remain untouched.
|
|
22
|
+
*/
|
|
23
|
+
export function stripClinePassDeepSeekV4ToolReplayNarration(
|
|
24
|
+
body: string,
|
|
25
|
+
modelId: string,
|
|
26
|
+
): string {
|
|
27
|
+
if (!isClinePassDeepSeekV4Model(modelId)) return body;
|
|
28
|
+
|
|
29
|
+
let parsed: unknown;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(body);
|
|
32
|
+
} catch {
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.messages)) return body;
|
|
36
|
+
|
|
37
|
+
let changed = false;
|
|
38
|
+
const messages = parsed.messages.map(message => {
|
|
39
|
+
if (!isRecord(message) || message.role !== "assistant") return message;
|
|
40
|
+
const toolCalls = message.tool_calls;
|
|
41
|
+
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return message;
|
|
42
|
+
if (message.content === "") return message;
|
|
43
|
+
|
|
44
|
+
changed = true;
|
|
45
|
+
return { ...message, content: "" };
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return changed ? JSON.stringify({ ...parsed, messages }) : body;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply the ClinePass DeepSeek V4 replay compatibility policy after the ordinary
|
|
53
|
+
* OpenAI-chat request has been serialized. The adapter's response parsing and all
|
|
54
|
+
* non-target request behavior stay identical.
|
|
55
|
+
*/
|
|
56
|
+
export function withClinePassDeepSeekV4ToolReplayCompatibility(
|
|
57
|
+
adapter: ProviderAdapter,
|
|
58
|
+
): ProviderAdapter {
|
|
59
|
+
return {
|
|
60
|
+
...adapter,
|
|
61
|
+
async buildRequest(parsed, incoming) {
|
|
62
|
+
const request = await adapter.buildRequest(parsed, incoming);
|
|
63
|
+
if (!isClinePassDeepSeekV4Model(parsed.modelId)) return request;
|
|
64
|
+
|
|
65
|
+
const body = stripClinePassDeepSeekV4ToolReplayNarration(request.body, parsed.modelId);
|
|
66
|
+
return body === request.body ? request : { ...request, body };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -553,6 +553,23 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
|
|
|
553
553
|
sawFinish = true;
|
|
554
554
|
const usageValue = event.totalUsage ?? event.usage;
|
|
555
555
|
const stopReason = typeof event.rawFinishReason === "string" ? event.rawFinishReason : typeof event.finishReason === "string" ? event.finishReason : undefined;
|
|
556
|
+
// The AI SDK's `error` finish reason means the generation failed upstream, not that it
|
|
557
|
+
// stopped. Reporting it as a `done` left the bridge to infer failure from a stop-reason
|
|
558
|
+
// string, which either read as a clean completion or (once classified) mislabelled an
|
|
559
|
+
// upstream error as a content filter and rejected it from the replay cache for the
|
|
560
|
+
// wrong reason.
|
|
561
|
+
if (stopReason === "error") {
|
|
562
|
+
// Keep the usage: a failed turn still consumed tokens, and dropping it makes the
|
|
563
|
+
// turn look free in accounting and reports zeros to the client.
|
|
564
|
+
yield {
|
|
565
|
+
type: "error",
|
|
566
|
+
message: "Command Code upstream ended the turn with finishReason \"error\"",
|
|
567
|
+
status: 502,
|
|
568
|
+
errorType: "upstream_error",
|
|
569
|
+
usage: usage(usageValue),
|
|
570
|
+
};
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
556
573
|
yield { type: "done", usage: usage(usageValue), stopReason };
|
|
557
574
|
break;
|
|
558
575
|
}
|
|
@@ -27,7 +27,56 @@ function errorCode(value: unknown): string {
|
|
|
27
27
|
* True when Cursor intentionally cancelled the HTTP/2 stream after a client-tool suspend.
|
|
28
28
|
* These are expected between multi-turn Responses bridge cycles, not upstream failures.
|
|
29
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* A Cursor stream that ended cleanly at the HTTP/2 layer while a client tool call was still
|
|
32
|
+
* open — no `turnEnded`, no error trailer, just EOF. The call's buffered arguments are lost,
|
|
33
|
+
* so the turn is truncated: reporting it as success would hand Codex a turn whose tool call
|
|
34
|
+
* silently never happened. Not retryable — the request is committed once the session connects.
|
|
35
|
+
*/
|
|
36
|
+
export class CursorStreamTruncatedError extends Error {
|
|
37
|
+
constructor(
|
|
38
|
+
public readonly openCallIds: readonly string[],
|
|
39
|
+
public readonly framesReceived: number,
|
|
40
|
+
) {
|
|
41
|
+
super(
|
|
42
|
+
`Cursor stream ended without terminating the turn; ${openCallIds.length} tool call(s) left incomplete `
|
|
43
|
+
+ `(${openCallIds.join(", ")}) after ${framesReceived} frame(s). Arguments may be truncated; the call was not committed.`,
|
|
44
|
+
);
|
|
45
|
+
this.name = "CursorStreamTruncatedError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A cancel-shaped stream failure that WE did not request. `cancelCursorRun` is the only place
|
|
51
|
+
* that cancels our own stream, and it sets `expectedClose` first, so a cancel arriving without it
|
|
52
|
+
* came from Cursor or the network and is a real transport failure.
|
|
53
|
+
*
|
|
54
|
+
* It carries its own message on purpose. Left as a raw `NGHTTP2_CANCEL` error, the text is
|
|
55
|
+
* re-matched downstream (`classifyCursorError`) and labelled "Cursor stream suspended" — a turn
|
|
56
|
+
* that failed unexpectedly would report an intentional suspension and misdirect diagnosis.
|
|
57
|
+
*/
|
|
58
|
+
export class CursorUnexpectedCancelError extends Error {
|
|
59
|
+
/**
|
|
60
|
+
* The originating error's transport code (typically `NGHTTP2_CANCEL`), re-exposed so the
|
|
61
|
+
* per-turn `turn-failed` diagnostic still records how the stream actually died. Wrapping
|
|
62
|
+
* without this made the summary for exactly this failure the one with no code.
|
|
63
|
+
*/
|
|
64
|
+
public readonly code?: string;
|
|
65
|
+
|
|
66
|
+
constructor(public readonly cause?: unknown) {
|
|
67
|
+
super("Cursor connection was cancelled by the server before the turn completed");
|
|
68
|
+
this.name = "CursorUnexpectedCancelError";
|
|
69
|
+
const causeCode = errorCode(cause);
|
|
70
|
+
if (causeCode) this.code = causeCode;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
30
74
|
export function isCursorBenignCancelError(value: unknown): boolean {
|
|
75
|
+
// An unexpected cancel is never benign, however it is spelled. This class is raised only when
|
|
76
|
+
// the transport knows WE did not request the cancel, so its provenance outranks the code match
|
|
77
|
+
// below — otherwise the adapter would re-decide the same question from the error code alone
|
|
78
|
+
// and swallow a real transport failure (cursor.ts:181).
|
|
79
|
+
if (value instanceof CursorUnexpectedCancelError) return false;
|
|
31
80
|
const message = errorMessage(value).toLowerCase();
|
|
32
81
|
const code = errorCode(value).toUpperCase();
|
|
33
82
|
if (code === "NGHTTP2_CANCEL") return true;
|
|
@@ -19,6 +19,8 @@ import { GetUsableModelsResponseSchema } from "./gen/agent_pb";
|
|
|
19
19
|
const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
|
|
20
20
|
const CURSOR_DISCOVERY_CLIENT_VERSION = "cli-2026.02.13-41ac335";
|
|
21
21
|
const CURSOR_MODEL_DISCOVERY_MAX_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
type CursorUsableModelsFetcher = (opts: CursorUsableModelsOptions) => Promise<CursorUsableModelsResult>;
|
|
23
|
+
let cursorUsableModelsFetcherForTests: CursorUsableModelsFetcher | null = null;
|
|
22
24
|
|
|
23
25
|
export interface CursorUsableModelsOptions {
|
|
24
26
|
apiKey: string;
|
|
@@ -31,6 +33,11 @@ export type CursorUsableModelsResult =
|
|
|
31
33
|
| { ok: true; models: string[] }
|
|
32
34
|
| { ok: false; error: "auth" | "http" | "transport" | "timeout" | "decode" | "empty" | "too_large"; detail?: string };
|
|
33
35
|
|
|
36
|
+
/** Test-only seam for management connectivity probes; production callers retain the HTTP/2 path. */
|
|
37
|
+
export function setFetchCursorUsableModelsForTests(next: CursorUsableModelsFetcher | null): void {
|
|
38
|
+
cursorUsableModelsFetcherForTests = next;
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
const RETRYABLE_DISCOVERY_ERRORS = new Set(["timeout", "transport"]);
|
|
35
42
|
const DISCOVERY_RETRY_TIMEOUT_MS = 3_000;
|
|
36
43
|
|
|
@@ -42,10 +49,37 @@ const DISCOVERY_RETRY_TIMEOUT_MS = 3_000;
|
|
|
42
49
|
* devlog 260723_cursor_context_continuity/030).
|
|
43
50
|
*/
|
|
44
51
|
export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
|
|
45
|
-
|
|
52
|
+
if (cursorUsableModelsFetcherForTests) return cursorUsableModelsFetcherForTests(opts);
|
|
53
|
+
const resolved = resolveCursorDiscoveryBaseUrl(opts.baseUrl ?? "https://api2.cursor.sh");
|
|
54
|
+
if (!resolved.ok) return resolved;
|
|
55
|
+
const first = await fetchCursorUsableModelsOnce({ ...opts, baseUrl: resolved.baseUrl });
|
|
46
56
|
if (first.ok || !RETRYABLE_DISCOVERY_ERRORS.has(first.error)) return first;
|
|
47
57
|
await new Promise(resolve => setTimeout(resolve, 250 + Math.floor(Math.random() * 250)));
|
|
48
|
-
return fetchCursorUsableModelsOnce({
|
|
58
|
+
return fetchCursorUsableModelsOnce({
|
|
59
|
+
...opts,
|
|
60
|
+
baseUrl: resolved.baseUrl,
|
|
61
|
+
timeoutMs: Math.min(opts.timeoutMs ?? 8000, DISCOVERY_RETRY_TIMEOUT_MS),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveCursorDiscoveryBaseUrl(raw: string): { ok: true; baseUrl: string } | Extract<CursorUsableModelsResult, { ok: false }> {
|
|
66
|
+
const baseUrl = raw.replace(/\/+$/, "");
|
|
67
|
+
let parsed: URL;
|
|
68
|
+
try {
|
|
69
|
+
parsed = new URL(baseUrl);
|
|
70
|
+
} catch {
|
|
71
|
+
return { ok: false, error: "transport", detail: "Cursor discovery URL is invalid" };
|
|
72
|
+
}
|
|
73
|
+
if (parsed.protocol === "https:") return { ok: true, baseUrl };
|
|
74
|
+
// Local h2c fixtures (and an operator loopback proxy) never leave the machine.
|
|
75
|
+
// Anything else with a Bearer token must be HTTPS, matching providerOutbound POST.
|
|
76
|
+
if (parsed.protocol === "http:") {
|
|
77
|
+
const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
78
|
+
if (host === "127.0.0.1" || host === "::1" || host === "localhost" || host.endsWith(".localhost")) {
|
|
79
|
+
return { ok: true, baseUrl };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { ok: false, error: "transport", detail: "Cursor discovery URL must use HTTPS" };
|
|
49
83
|
}
|
|
50
84
|
|
|
51
85
|
async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
type InteractionResponse,
|
|
49
49
|
} from "./gen/agent_pb";
|
|
50
50
|
import { debugProviderDiagnostic } from "../../lib/debug";
|
|
51
|
-
import { classifyCursorError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
|
|
51
|
+
import { classifyCursorError, CursorUnexpectedCancelError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
|
|
52
52
|
import { mcpArgsFromToolCall } from "./protobuf-events";
|
|
53
53
|
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
|
|
54
54
|
import {
|
|
@@ -410,6 +410,12 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
410
410
|
private firstFrameTimer?: ReturnType<typeof setTimeout>;
|
|
411
411
|
private committed = false;
|
|
412
412
|
private expectedClose = false;
|
|
413
|
+
/**
|
|
414
|
+
* True once a terminal (`done` or `error`) has been admitted to the outbound queue. Read only
|
|
415
|
+
* by the EOF branch below: after a mapper error the bridge has already failed the turn, so
|
|
416
|
+
* failing again on EOF would add a duplicate adapter error for no benefit.
|
|
417
|
+
*/
|
|
418
|
+
private emittedTerminal = false;
|
|
413
419
|
private pendingFinalize?: ReturnType<typeof setTimeout>;
|
|
414
420
|
private readonly clientToolFinalizeGraceMs: number;
|
|
415
421
|
private activeClientToolFinalizeGraceMs: number;
|
|
@@ -428,6 +434,7 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
428
434
|
// close; safe to read after a stream failure because open() owns the only writer before run().
|
|
429
435
|
private turnStartedAt = 0;
|
|
430
436
|
private framesReceived = 0;
|
|
437
|
+
private sawAssistantText = false;
|
|
431
438
|
private firstFrameAt?: number;
|
|
432
439
|
private firstFrameLogged = false;
|
|
433
440
|
/** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */
|
|
@@ -522,6 +529,22 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
522
529
|
}
|
|
523
530
|
return err;
|
|
524
531
|
};
|
|
532
|
+
/**
|
|
533
|
+
* A cancel we did not request is a real transport failure, but as a raw `NGHTTP2_CANCEL` it
|
|
534
|
+
* gets swallowed twice over: the adapter re-decides "benign" from the error code alone
|
|
535
|
+
* (`cursor.ts:181`) and drops the turn, and any message that survives is re-matched
|
|
536
|
+
* downstream and labelled an intentional "Cursor stream suspended". Raising a typed error
|
|
537
|
+
* carries the provenance this class already holds.
|
|
538
|
+
*
|
|
539
|
+
* Suppressed once a terminal was emitted: the turn already ended, and a second terminal flips
|
|
540
|
+
* a completed buffered response to failed.
|
|
541
|
+
*/
|
|
542
|
+
const classifyTurnFailure = (err: Error): Error => {
|
|
543
|
+
if (!this.expectedClose && !this.emittedTerminal && isCursorBenignCancelError(err)) {
|
|
544
|
+
return summarizeFailure(new CursorUnexpectedCancelError(err));
|
|
545
|
+
}
|
|
546
|
+
return summarizeFailure(err);
|
|
547
|
+
};
|
|
525
548
|
const wake = () => {
|
|
526
549
|
const fn = notify;
|
|
527
550
|
notify = undefined;
|
|
@@ -531,6 +554,7 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
531
554
|
const push = (message: CursorServerMessage) => {
|
|
532
555
|
const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
533
556
|
this.reserveTransportBytes(bytes);
|
|
557
|
+
if (message.type === "done" || message.type === "error") this.emittedTerminal = true;
|
|
534
558
|
queue.push({ message, bytes });
|
|
535
559
|
wake();
|
|
536
560
|
};
|
|
@@ -620,7 +644,7 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
620
644
|
// A CANCEL is benign only on the client-tool suspend path (expectedClose); an
|
|
621
645
|
// unexpected server-side NGHTTP2_CANCEL must surface as a real transport error.
|
|
622
646
|
if (this.expectedClose && isCursorBenignCancelError(failure)) return;
|
|
623
|
-
throw attachPartialUsage(
|
|
647
|
+
throw attachPartialUsage(classifyTurnFailure(failure), state);
|
|
624
648
|
}
|
|
625
649
|
if (done) break;
|
|
626
650
|
await new Promise<void>(resolve => {
|
|
@@ -629,7 +653,7 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
629
653
|
}
|
|
630
654
|
if (failure) {
|
|
631
655
|
if (this.expectedClose && isCursorBenignCancelError(failure)) return;
|
|
632
|
-
throw attachPartialUsage(
|
|
656
|
+
throw attachPartialUsage(classifyTurnFailure(failure), state);
|
|
633
657
|
}
|
|
634
658
|
}
|
|
635
659
|
|
|
@@ -769,6 +793,8 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
769
793
|
): void {
|
|
770
794
|
this.turnStartedAt = Date.now();
|
|
771
795
|
this.framesReceived = 0;
|
|
796
|
+
this.sawAssistantText = false;
|
|
797
|
+
this.emittedTerminal = false;
|
|
772
798
|
this.firstFrameAt = undefined;
|
|
773
799
|
this.firstFrameLogged = false;
|
|
774
800
|
const dialHost = cursorHostLabel(this.input.provider.baseUrl || "https://api2.cursor.sh");
|
|
@@ -1026,6 +1052,27 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
1026
1052
|
settler.settleFail(new Error("Cursor stream ended before any response frame (unexpected EOF)"));
|
|
1027
1053
|
return;
|
|
1028
1054
|
}
|
|
1055
|
+
// `emittedTerminal` joins dev's two conditions so EOF finalization cannot append a
|
|
1056
|
+
// second terminal after a mapper error already failed the turn (integration 010).
|
|
1057
|
+
if (state.terminated || this.expectedClose || this.emittedTerminal) {
|
|
1058
|
+
releaseBacklogLease();
|
|
1059
|
+
settler.settleFinish();
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
// Open tools fail-closed as a truncation *event* (finalizeTurnEvents), not a thrown
|
|
1063
|
+
// transport error. settleFail here would hide that typed message as adapter_eof.
|
|
1064
|
+
if (state.openToolCalls.size > 0) {
|
|
1065
|
+
for (const event of finalizeTurnEvents(state)) push(event);
|
|
1066
|
+
releaseBacklogLease();
|
|
1067
|
+
settler.settleFinish();
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (this.framesReceived > 0 && this.sawAssistantText) {
|
|
1071
|
+
for (const event of finalizeTurnEvents(state)) push(event);
|
|
1072
|
+
releaseBacklogLease();
|
|
1073
|
+
settler.settleFinish();
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1029
1076
|
releaseBacklogLease();
|
|
1030
1077
|
settler.settleFinish();
|
|
1031
1078
|
}, (err) => {
|
|
@@ -1087,7 +1134,10 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
1087
1134
|
debugProviderDiagnostic("cursor", "interaction-query", { id: query.id, queryCase: query.query.case ?? "unknown", reply: plan.replyCase });
|
|
1088
1135
|
this.stream.write(encodeClientMessage({ message: { case: "interactionResponse", value: plan.response } }));
|
|
1089
1136
|
if (!state.terminated) {
|
|
1090
|
-
if (plan.planText)
|
|
1137
|
+
if (plan.planText) {
|
|
1138
|
+
this.sawAssistantText = true;
|
|
1139
|
+
push({ type: "text", text: plan.planText });
|
|
1140
|
+
}
|
|
1091
1141
|
push({ type: "heartbeat" });
|
|
1092
1142
|
}
|
|
1093
1143
|
return;
|
|
@@ -1100,6 +1150,7 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
1100
1150
|
const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted"
|
|
1101
1151
|
&& state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true;
|
|
1102
1152
|
const mapped = mapCursorProtobufServerMessage(message, state);
|
|
1153
|
+
if (mapped.some(event => event.type === "text")) this.sawAssistantText = true;
|
|
1103
1154
|
const beganAwaitingNativeClientToolArgs = update?.case === "toolCallCompleted"
|
|
1104
1155
|
&& !awaitedNativeArgsBeforeMapping
|
|
1105
1156
|
&& state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true;
|
|
@@ -461,6 +461,15 @@ export function setCursorBlobLimitsForTests(limits?: Partial<CursorBlobLimits>):
|
|
|
461
461
|
blobLimits = limits ? { ...DEFAULT_BLOB_LIMITS, ...limits } : { ...DEFAULT_BLOB_LIMITS };
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
/**
|
|
465
|
+
* The live per-blob admission ceiling. Callers that build a blob must budget against THIS value
|
|
466
|
+
* rather than a copy of the constant: the limit is test-overridable, and a hardcoded 16 MiB would
|
|
467
|
+
* silently drift from admission the moment either side changes.
|
|
468
|
+
*/
|
|
469
|
+
export function cursorBlobMaxEntryBytes(): number {
|
|
470
|
+
return blobLimits.maxEntryBytes;
|
|
471
|
+
}
|
|
472
|
+
|
|
464
473
|
export function resetCursorBlobStateForTests(): void {
|
|
465
474
|
if (blobExpiryAccountingTimer) clearTimeout(blobExpiryAccountingTimer);
|
|
466
475
|
blobExpiryAccountingTimer = undefined;
|