@bitkyc08/opencodex 2.30.0-preview.20260821 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/gui/dist/assets/index-DkcRs1fL.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/cursor-errors.ts +65 -6
- package/src/adapters/cursor/discovery.ts +7 -2
- package/src/adapters/cursor/effort-map.ts +6 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/live-models.ts +21 -26
- package/src/adapters/cursor/live-transport.ts +213 -3
- package/src/adapters/cursor/native-exec-common.ts +17 -0
- package/src/adapters/cursor/native-exec.ts +9 -4
- package/src/adapters/cursor/protobuf-events.ts +5 -1
- package/src/adapters/cursor/protobuf-request.ts +11 -4
- package/src/adapters/cursor/tool-definitions.ts +20 -0
- package/src/adapters/cursor/transport.ts +10 -0
- package/src/adapters/cursor.ts +23 -5
- package/src/adapters/google.ts +16 -3
- package/src/adapters/openai-responses.ts +66 -20
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/cli/agent.ts +2 -1
- package/src/cli/dispatch.ts +2 -2
- package/src/cli/doctor.ts +89 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/registry.ts +7 -2
- package/src/codex/auth-context.ts +41 -2
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-fetch.ts +20 -5
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/inject-coordination.ts +39 -6
- package/src/codex/transition-state.ts +12 -12
- package/src/generated/compatibility-version.json +74 -50
- package/src/lib/errors.ts +8 -2
- package/src/oauth/cursor.ts +21 -0
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +3 -0
- package/src/providers/fastwire.ts +12 -1
- package/src/providers/openai-sidecar.ts +1 -0
- package/src/providers/registry.ts +25 -0
- package/src/providers/service-tier.ts +22 -7
- package/src/responses/custom-tool-compat.ts +24 -8
- package/src/responses/namespace-tool-compat.ts +2 -3
- package/src/router.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/chat-native.ts +20 -0
- package/src/server/management/agent-settings-routes.ts +16 -5
- package/src/server/management/config-routes.ts +25 -5
- package/src/server/management/vision-sidecar-options.ts +54 -19
- package/src/server/responses/compact.ts +1 -2
- package/src/server/responses/core.ts +54 -13
- package/src/service.ts +122 -14
- package/src/types/config.ts +9 -3
- package/src/types/provider.ts +6 -0
- package/src/usage/cost.ts +52 -38
- package/src/usage/expected-prices.ts +79 -9
- package/src/vision/backends.ts +97 -0
- package/src/vision/eligibility.ts +43 -22
- package/src/vision/index.ts +73 -5
- package/src/vision/routed-describe.ts +175 -0
- package/gui/dist/assets/index-eBA05kYB.js +0 -102
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-DkcRs1fL.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-CH7ncHCC.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.31.0",
|
|
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",
|
|
@@ -112,6 +112,58 @@ export function isCursorInvalidArgumentError(value: unknown): boolean {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
const QUOTA_RATE_CUES = ["too many requests", "quota", "rate limit", "rate-limit", "throttl"];
|
|
115
|
+
/**
|
|
116
|
+
* A bare `resource_exhausted` end-stream with no detail beyond a generic error wrapper
|
|
117
|
+
* ("Error" or empty tail) and zero tokens billed is the shape Cursor's backend emits when
|
|
118
|
+
* the request payload exceeded its context window — not when quota ran out (senpi #1009,
|
|
119
|
+
* #1036: same wording, two causes). Quota rejections always carry an explicit rate cue
|
|
120
|
+
* ("too many requests", "quota exhausted"), so the ABSENCE of those cues plus the
|
|
121
|
+
* absence of a size phrase means payload overflow. Classifying it as 429 makes Codex
|
|
122
|
+
* back off instead of compacting, which burns retries on an unfixable-by-retry failure.
|
|
123
|
+
*/
|
|
124
|
+
const BARE_RE_TAILS = new Set(["error", "", "resource_exhausted", "resource exhausted"]);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Size prior for bare resource_exhausted classification (devlog 260, live probe 210):
|
|
128
|
+
* a plan-gated model returns the SAME bare RE shape on a ~20-token prompt that a real
|
|
129
|
+
* payload overflow produces, so the message alone cannot separate "compact and retry"
|
|
130
|
+
* from "this account cannot use this model". When the caller can supply how large the
|
|
131
|
+
* request actually was relative to the model's window, a small request keeps the
|
|
132
|
+
* 429-class mapping; only a plausibly-large one classifies as overflow. Unknown
|
|
133
|
+
* sizes keep today's overflow mapping so the prior only ever REMOVES false overflows
|
|
134
|
+
* it can prove.
|
|
135
|
+
*/
|
|
136
|
+
export interface CursorSizeContext {
|
|
137
|
+
estimatedInputTokens?: number;
|
|
138
|
+
contextWindow?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const OVERFLOW_MIN_FRACTION = 0.5;
|
|
142
|
+
|
|
143
|
+
function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean {
|
|
144
|
+
if (!context) return true;
|
|
145
|
+
const { estimatedInputTokens, contextWindow } = context;
|
|
146
|
+
if (estimatedInputTokens === undefined || contextWindow === undefined || contextWindow <= 0) return true;
|
|
147
|
+
return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean {
|
|
151
|
+
if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false;
|
|
152
|
+
// Any explicit quota/rate cue wins: this is a real 429.
|
|
153
|
+
if (QUOTA_RATE_CUES.some(cue => lowerMessage.includes(cue))) return false;
|
|
154
|
+
// An explicit size phrase also wins (already handled by the existing classifier).
|
|
155
|
+
if (isCursorRequestTooLargeDetail(lowerMessage)) return false;
|
|
156
|
+
// Extract the tail after the resource_exhausted marker. If it names a specific
|
|
157
|
+
// non-quota, non-size cause, this is NOT bare overflow.
|
|
158
|
+
const idx = Math.max(
|
|
159
|
+
lowerMessage.indexOf("resource_exhausted"),
|
|
160
|
+
lowerMessage.indexOf("resource exhausted"),
|
|
161
|
+
);
|
|
162
|
+
const tail = lowerMessage.slice(idx + "resource_exhausted".length).trim().replace(/^[:\s]+/, "").trim();
|
|
163
|
+
if (!BARE_RE_TAILS.has(tail)) return false;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
115
167
|
const REQUEST_TOO_LARGE_PATTERNS: (string | RegExp)[] = [
|
|
116
168
|
"tool catalog too large",
|
|
117
169
|
"tool registration too large",
|
|
@@ -144,7 +196,7 @@ export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean {
|
|
|
144
196
|
* The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords,
|
|
145
197
|
* so bridge-level error mapping produces the right Codex error type (rate_limit, auth, etc.).
|
|
146
198
|
*/
|
|
147
|
-
export function classifyCursorError(message: string): string {
|
|
199
|
+
export function classifyCursorError(message: string, sizeContext?: CursorSizeContext): string {
|
|
148
200
|
const lower = message.toLowerCase();
|
|
149
201
|
|
|
150
202
|
if (isCursorBenignCancelError(message)) return "Cursor stream suspended";
|
|
@@ -158,9 +210,16 @@ export function classifyCursorError(message: string): string {
|
|
|
158
210
|
// client-fixable 400; everything else surfaces as a 429 so Codex backs off
|
|
159
211
|
// instead of hammering retries (live evidence: 6x 400 retry storm, devlog
|
|
160
212
|
// 260723_cursor_context_continuity/000_plan.md).
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
213
|
+
if (isCursorRequestTooLargeDetail(lower)) return "Cursor resource limit exceeded";
|
|
214
|
+
// A bare resource_exhausted with no quota cue and no size phrase is payload
|
|
215
|
+
// overflow, not rate limiting. Classifying it as 429 makes Codex back off on a
|
|
216
|
+
// failure that only compaction can fix (senpi #1009 / #1036; research unit T01).
|
|
217
|
+
// Refinement (devlog 260): plan-gated models emit the same bare shape on tiny
|
|
218
|
+
// requests — when the caller proves the request was small, keep the 429 class.
|
|
219
|
+
if (isCursorZeroTokenResourceExhausted(lower)) {
|
|
220
|
+
return bareReLooksLikeOverflow(sizeContext) ? "Cursor context limit exceeded" : "Cursor rate limit exceeded";
|
|
221
|
+
}
|
|
222
|
+
return "Cursor rate limit exceeded";
|
|
164
223
|
}
|
|
165
224
|
|
|
166
225
|
if (
|
|
@@ -220,8 +279,8 @@ export function classifyCursorError(message: string): string {
|
|
|
220
279
|
* Produce a user-facing, secret-safe Cursor error message with an actionable category prefix.
|
|
221
280
|
* Mirrors `safeKiroErrorMessage` / `safeKiroHttpErrorMessage` in kiro-errors.ts.
|
|
222
281
|
*/
|
|
223
|
-
export function safeCursorErrorMessage(rawMessage: string): string {
|
|
224
|
-
const prefix = classifyCursorError(rawMessage);
|
|
282
|
+
export function safeCursorErrorMessage(rawMessage: string, sizeContext?: CursorSizeContext): string {
|
|
283
|
+
const prefix = classifyCursorError(rawMessage, sizeContext);
|
|
225
284
|
const detail = sanitize(rawMessage)
|
|
226
285
|
.replace(/resource[_ ]exhausted/gi, "resource limit exceeded")
|
|
227
286
|
.slice(0, 500);
|
|
@@ -236,10 +236,15 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
|
|
|
236
236
|
{ id: "claude-4.6-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
237
237
|
{ id: "claude-4.6-sonnet", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
238
238
|
{ id: "claude-opus-4-7", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
239
|
-
//
|
|
240
|
-
{
|
|
239
|
+
// Opus Fast families: live GetUsableModels (260822) lists ONLY effort-suffixed wire ids
|
|
240
|
+
// ({base-without-fast}-{effort}-fast; the bare id returns not_found), so every entry
|
|
241
|
+
// carries a tier picker. Live-verified: claude-opus-4-8-high-fast completed a turn.
|
|
242
|
+
// Tiers per the 260822 dump (devlog 260822_senpi_cursor_transfer/300).
|
|
243
|
+
{ id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
244
|
+
{ id: "claude-opus-4-8-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
241
245
|
{ id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
242
246
|
{ id: "claude-opus-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
247
|
+
{ id: "claude-opus-5-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
243
248
|
{ id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
|
|
244
249
|
|
|
245
250
|
{ id: "composer-1", contextWindow: CONTEXT_200K },
|
|
@@ -24,8 +24,14 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
|
|
|
24
24
|
// against Anthropic's effort ladder docs and Cursor's live model lineup.
|
|
25
25
|
"claude-fable-5": ["low", "medium", "high", "xhigh", "max"],
|
|
26
26
|
"claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"],
|
|
27
|
+
// Opus Fast tiers from the 260822 GetUsableModels dump (devlog .../300): the wire
|
|
28
|
+
// exposes {base-without-fast}-{effort}-fast only; suffix derivation at the bottom of
|
|
29
|
+
// this file produces those ids. opus-5-fast has no xhigh/max (non-thinking) yet.
|
|
30
|
+
"claude-opus-4-7-fast": ["low", "medium", "high", "xhigh", "max"],
|
|
27
31
|
"claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"],
|
|
32
|
+
"claude-opus-4-8-fast": ["low", "medium", "high", "xhigh", "max"],
|
|
28
33
|
"claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
|
|
34
|
+
"claude-opus-5-fast": ["low", "medium", "high"],
|
|
29
35
|
"claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
|
|
30
36
|
"glm-5.2": ["high", "max"],
|
|
31
37
|
// 260814 preemptive: glm-5.3 seeded ahead of Cursor's lineup update. Unlike 5.2, Z.AI folds
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import http2 from "node:http2";
|
|
2
|
+
import { registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_SESSIONS = 8;
|
|
5
|
+
const SESSION_CLOSE_TIMEOUT_MS = 2_000;
|
|
6
|
+
|
|
7
|
+
interface PoolEntry {
|
|
8
|
+
readonly session: http2.ClientHttp2Session;
|
|
9
|
+
readonly streams: Set<http2.ClientHttp2Stream>;
|
|
10
|
+
usable: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* HTTP/2 connection pool for Cursor Connect DISCOVERY calls (GetUsableModels).
|
|
15
|
+
* Sessions are keyed by origin (scheme+host+port) and reused to avoid fresh
|
|
16
|
+
* TCP+TLS per call. The Run path deliberately dials its own session: Run
|
|
17
|
+
* streams are long-lived bidi whose lifecycle/EOF semantics are owned by
|
|
18
|
+
* live-transport (see devlog 260822_senpi_cursor_transfer/190 — Run-path
|
|
19
|
+
* pooling is a separate, deliberate unit if ever taken).
|
|
20
|
+
*/
|
|
21
|
+
export class CursorH2SessionPool {
|
|
22
|
+
private readonly entries = new Map<string, PoolEntry>();
|
|
23
|
+
private closed = false;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly maxSessions = DEFAULT_MAX_SESSIONS) {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lazily registered on first use so a process that never talks to Cursor registers
|
|
29
|
+
* nothing (optional-subsystem doctrine). The seam is synchronous and best-effort;
|
|
30
|
+
* shutdown() is fire-and-forget there because lifecycle's drainAndShutdown runs
|
|
31
|
+
* under its own absolute deadline.
|
|
32
|
+
*/
|
|
33
|
+
private armShutdownHook: (() => void) | undefined = () => {
|
|
34
|
+
this.armShutdownHook = undefined;
|
|
35
|
+
registerOptionalShutdownHook("cursor-h2-pool", () => { void this.shutdown(); });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
request(
|
|
39
|
+
url: string,
|
|
40
|
+
headers: http2.OutgoingHttpHeaders,
|
|
41
|
+
): http2.ClientHttp2Stream {
|
|
42
|
+
if (this.closed) throw new Error("Cursor H2 session pool is closed");
|
|
43
|
+
this.armShutdownHook?.();
|
|
44
|
+
const origin = new URL(url).origin;
|
|
45
|
+
const entry = this.usableEntry(origin) ?? this.createEntry(origin);
|
|
46
|
+
try {
|
|
47
|
+
const stream = entry.session.request(headers);
|
|
48
|
+
entry.streams.add(stream);
|
|
49
|
+
stream.once("close", () => { entry.streams.delete(stream); });
|
|
50
|
+
return stream;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
this.drain(entry, true);
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async shutdown(): Promise<void> {
|
|
58
|
+
if (this.closed) return;
|
|
59
|
+
this.closed = true;
|
|
60
|
+
const pending: Promise<void>[] = [];
|
|
61
|
+
for (const entry of [...this.entries.values()]) {
|
|
62
|
+
for (const stream of [...entry.streams]) stream.destroy();
|
|
63
|
+
entry.session.close();
|
|
64
|
+
if (entry.session.destroyed) continue;
|
|
65
|
+
pending.push(new Promise<void>(resolve => {
|
|
66
|
+
const timer = setTimeout(resolve, SESSION_CLOSE_TIMEOUT_MS);
|
|
67
|
+
timer.unref?.();
|
|
68
|
+
entry.session.once("close", () => { clearTimeout(timer); resolve(); });
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
this.entries.clear();
|
|
72
|
+
await Promise.all(pending);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get size(): number { return this.entries.size; }
|
|
76
|
+
|
|
77
|
+
private usableEntry(origin: string): PoolEntry | undefined {
|
|
78
|
+
const entry = this.entries.get(origin);
|
|
79
|
+
if (!entry) return undefined;
|
|
80
|
+
if (entry.usable && !entry.session.closed && !entry.session.destroyed) return entry;
|
|
81
|
+
this.drain(entry, false);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private createEntry(origin: string): PoolEntry {
|
|
86
|
+
const session = http2.connect(origin);
|
|
87
|
+
const entry: PoolEntry = {
|
|
88
|
+
session,
|
|
89
|
+
streams: new Set(),
|
|
90
|
+
usable: true,
|
|
91
|
+
};
|
|
92
|
+
this.entries.set(origin, entry);
|
|
93
|
+
session.once("goaway", () => { this.drain(entry, true); });
|
|
94
|
+
session.on("error", () => { this.drain(entry, false); });
|
|
95
|
+
session.once("close", () => {
|
|
96
|
+
// Identity check: a stale close event from an old session must not evict
|
|
97
|
+
// a healthy replacement entry that was created after drain() removed the old one.
|
|
98
|
+
if (this.entries.get(origin) === entry) this.entries.delete(origin);
|
|
99
|
+
});
|
|
100
|
+
// Enforce bound: evict oldest when over capacity.
|
|
101
|
+
while (this.entries.size > this.maxSessions) {
|
|
102
|
+
const oldest = this.entries.keys().next().value;
|
|
103
|
+
if (!oldest || oldest === origin) break;
|
|
104
|
+
const old = this.entries.get(oldest);
|
|
105
|
+
if (old) this.drain(old, true);
|
|
106
|
+
}
|
|
107
|
+
return entry;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private drain(entry: PoolEntry, closeSession: boolean): void {
|
|
111
|
+
entry.usable = false;
|
|
112
|
+
for (const stream of [...entry.streams]) stream.destroy();
|
|
113
|
+
entry.streams.clear();
|
|
114
|
+
if (closeSession) entry.session.close();
|
|
115
|
+
// Remove from map by finding the matching key.
|
|
116
|
+
for (const [key, value] of this.entries) {
|
|
117
|
+
if (value === entry) { this.entries.delete(key); break; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Shared singleton pool for all Cursor adapter H2 traffic. */
|
|
123
|
+
export const cursorH2Pool = new CursorH2SessionPool();
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* 5-byte gRPC/Connect frame makes the server mis-parse it ("illegal tag: field no 0").
|
|
15
15
|
*/
|
|
16
16
|
import http2 from "node:http2";
|
|
17
|
+
import { cursorH2Pool } from "./h2-pool";
|
|
17
18
|
import { fromBinary } from "@bufbuild/protobuf";
|
|
18
19
|
import type { UpstreamHttpVersion } from "../../types";
|
|
19
20
|
import { readBoundedResponseBytes } from "../../lib/bounded-body";
|
|
@@ -205,35 +206,29 @@ async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions)
|
|
|
205
206
|
resolve(value);
|
|
206
207
|
};
|
|
207
208
|
|
|
208
|
-
let client: http2.ClientHttp2Session;
|
|
209
|
-
try {
|
|
210
|
-
client = http2.connect(baseUrl);
|
|
211
|
-
} catch {
|
|
212
|
-
return finish({ ok: false, error: "transport", detail: "HTTP/2 connection setup failed" });
|
|
213
|
-
}
|
|
214
209
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
210
|
+
const timer = setTimeout(() => {
|
|
211
|
+
// Cancel the borrowed pooled stream so it does not continue receiving
|
|
212
|
+
// body bytes after the caller has timed out (regression vs pre-pool behavior).
|
|
213
|
+
req?.destroy();
|
|
214
|
+
finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` });
|
|
215
|
+
}, timeoutMs);
|
|
216
|
+
const close = (value: CursorUsableModelsResult): void => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
finish(value);
|
|
219
|
+
};
|
|
224
220
|
|
|
225
|
-
client.on("error", () => close({ ok: false, error: "transport", detail: "HTTP/2 session failed" }));
|
|
226
221
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
req =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
222
|
+
let req: http2.ClientHttp2Stream;
|
|
223
|
+
try {
|
|
224
|
+
req = cursorH2Pool.request(baseUrl, {
|
|
225
|
+
":method": "POST",
|
|
226
|
+
":path": CURSOR_GET_USABLE_MODELS_PATH,
|
|
227
|
+
...cursorDiscoveryHeaders(opts),
|
|
228
|
+
});
|
|
229
|
+
} catch {
|
|
230
|
+
return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" });
|
|
231
|
+
}
|
|
237
232
|
|
|
238
233
|
let status = 0;
|
|
239
234
|
const chunks: Buffer[] = [];
|
|
@@ -91,6 +91,24 @@ const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run";
|
|
|
91
91
|
const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a";
|
|
92
92
|
const HEARTBEAT_MS = 5_000;
|
|
93
93
|
const CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000;
|
|
94
|
+
/**
|
|
95
|
+
* T04 (senpi #1062 second half): after the first frame, a turn with NO inbound decoded
|
|
96
|
+
* frames for this long is failed instead of waiting for the 300s bridge stall watchdog
|
|
97
|
+
* (issue #2210). Reset on every decoded AgentServerMessage.
|
|
98
|
+
*/
|
|
99
|
+
const CURSOR_STREAM_SILENCE_FAIL_MS = 30_000;
|
|
100
|
+
/**
|
|
101
|
+
* A stream that produces ONLY liveness frames (server heartbeat / conversationCheckpointUpdate)
|
|
102
|
+
* for this long is equally stuck — the server is alive but the turn is not progressing.
|
|
103
|
+
* Reset on every decoded frame that is not liveness-only.
|
|
104
|
+
*/
|
|
105
|
+
const CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS = 90_000;
|
|
106
|
+
/**
|
|
107
|
+
* After `turnEnded` is decoded, the application turn is complete. A server that keeps
|
|
108
|
+
* HTTP/2 open past this point cannot hold the turn hostage (senpi #1062): we close our side
|
|
109
|
+
* after a short grace so any trailing frames (late usage, checkpoint) still land.
|
|
110
|
+
*/
|
|
111
|
+
const TURN_ENDED_CLOSE_GRACE_MS = 500;
|
|
94
112
|
const CURSOR_TIMEOUT_DESTROY_GRACE_MS = 1_000;
|
|
95
113
|
const CLIENT_TOOL_FINALIZE_GRACE_MS = 50;
|
|
96
114
|
const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750;
|
|
@@ -414,6 +432,18 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
414
432
|
private http1Connection?: CursorHttp1BidiConnection;
|
|
415
433
|
private heartbeat?: ReturnType<typeof setInterval>;
|
|
416
434
|
private firstFrameTimer?: ReturnType<typeof setTimeout>;
|
|
435
|
+
private turnEndedCloseTimer?: ReturnType<typeof setTimeout>;
|
|
436
|
+
/**
|
|
437
|
+
* T04 inbound stream-health watchdog. Armed after the request is on the wire, reset by
|
|
438
|
+
* every DECODED frame (raw chunks deliberately do not count — TLS keepalive noise must not
|
|
439
|
+
* defeat it), disarmed by any settle/expected-close path. One timer covers both thresholds:
|
|
440
|
+
* it always fires at min(lastInbound + silence, lastMeaningful + heartbeatOnly) and re-arms
|
|
441
|
+
* when neither deadline has actually elapsed.
|
|
442
|
+
*/
|
|
443
|
+
private streamHealthTimer?: ReturnType<typeof setTimeout>;
|
|
444
|
+
private lastInboundFrameAt = 0;
|
|
445
|
+
private lastMeaningfulFrameAt = 0;
|
|
446
|
+
private streamHealthFail?: (error: Error) => void;
|
|
417
447
|
private committed = false;
|
|
418
448
|
private expectedClose = false;
|
|
419
449
|
/**
|
|
@@ -753,14 +783,100 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
753
783
|
}
|
|
754
784
|
}
|
|
755
785
|
|
|
786
|
+
private clearStreamHealthTimer(): void {
|
|
787
|
+
if (this.streamHealthTimer) {
|
|
788
|
+
clearTimeout(this.streamHealthTimer);
|
|
789
|
+
this.streamHealthTimer = undefined;
|
|
790
|
+
}
|
|
791
|
+
this.streamHealthFail = undefined;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* T04: arm (or re-arm) the inbound stream-health watchdog. `fail` is the turn's
|
|
796
|
+
* failAndClear; the timer owns nothing else. Never armed before the first decoded
|
|
797
|
+
* frame (the first-frame timer covers dial + first response), and disarmed by
|
|
798
|
+
* every settle / expected-close path alongside the other timers.
|
|
799
|
+
*/
|
|
800
|
+
private armStreamHealthTimer(fail: (error: Error) => void): void {
|
|
801
|
+
if (this.streamHealthTimer) clearTimeout(this.streamHealthTimer);
|
|
802
|
+
if (this.expectedClose) return;
|
|
803
|
+
this.streamHealthFail = fail;
|
|
804
|
+
const silenceMs = this.input.streamSilenceFailMs ?? CURSOR_STREAM_SILENCE_FAIL_MS;
|
|
805
|
+
const heartbeatOnlyMs = this.input.streamHeartbeatOnlyFailMs ?? CURSOR_STREAM_HEARTBEAT_ONLY_FAIL_MS;
|
|
806
|
+
const now = Date.now();
|
|
807
|
+
const deadline = Math.min(
|
|
808
|
+
this.lastInboundFrameAt + silenceMs,
|
|
809
|
+
this.lastMeaningfulFrameAt + heartbeatOnlyMs,
|
|
810
|
+
);
|
|
811
|
+
this.streamHealthTimer = setTimeout(() => {
|
|
812
|
+
this.streamHealthTimer = undefined;
|
|
813
|
+
const failFn = this.streamHealthFail;
|
|
814
|
+
if (!failFn || this.expectedClose) return;
|
|
815
|
+
const stalledFor = Date.now() - this.lastInboundFrameAt;
|
|
816
|
+
const meaningfulStalledFor = Date.now() - this.lastMeaningfulFrameAt;
|
|
817
|
+
if (stalledFor < silenceMs && meaningfulStalledFor < heartbeatOnlyMs) {
|
|
818
|
+
// A frame landed between arming and firing — re-arm for the fresh deadline.
|
|
819
|
+
this.armStreamHealthTimer(failFn);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const heartbeatOnly = stalledFor < silenceMs;
|
|
823
|
+
debugProviderDiagnostic("cursor", "stream-health-timeout", {
|
|
824
|
+
stalledMs: stalledFor,
|
|
825
|
+
meaningfulStalledMs: meaningfulStalledFor,
|
|
826
|
+
heartbeatOnly,
|
|
827
|
+
framesReceived: this.framesReceived,
|
|
828
|
+
elapsedMs: Date.now() - this.turnStartedAt,
|
|
829
|
+
});
|
|
830
|
+
const reason = heartbeatOnly
|
|
831
|
+
? `Cursor stream stalled: heartbeat-only traffic for ${Math.round(meaningfulStalledFor / 1000)}s without turn progress`
|
|
832
|
+
: `Cursor stream stalled: no inbound frames for ${Math.round(stalledFor / 1000)}s before turnEnded`;
|
|
833
|
+
failFn(new Error(reason));
|
|
834
|
+
try { this.stream?.close(); } catch { this.stream?.destroy(); }
|
|
835
|
+
this.session?.close();
|
|
836
|
+
this.http1Connection?.close();
|
|
837
|
+
}, Math.max(0, deadline - now));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* T04: record a decoded inbound frame. Liveness-only frames (server heartbeat,
|
|
842
|
+
* conversationCheckpointUpdate) keep the silence clock fresh but not the progress
|
|
843
|
+
* clock — matching senpi's split so a server that only pings still fails at the
|
|
844
|
+
* heartbeat-only threshold.
|
|
845
|
+
*/
|
|
846
|
+
private noteInboundFrame(livenessOnly: boolean): void {
|
|
847
|
+
const now = Date.now();
|
|
848
|
+
this.lastInboundFrameAt = now;
|
|
849
|
+
if (!livenessOnly) this.lastMeaningfulFrameAt = now;
|
|
850
|
+
if (this.streamHealthFail) this.armStreamHealthTimer(this.streamHealthFail);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* A clean Connect END_STREAM owns the turn terminal even when Cursor keeps the
|
|
855
|
+
* HTTP body open or tears it down with an abort/reset immediately afterward.
|
|
856
|
+
* Stop client-side liveness work and classify that later transport close as
|
|
857
|
+
* expected without actively sending an RST_STREAM back to Cursor.
|
|
858
|
+
*/
|
|
859
|
+
private markProtocolComplete(): void {
|
|
860
|
+
this.expectedClose = true;
|
|
861
|
+
this.clearPendingFinalize();
|
|
862
|
+
if (this.heartbeat) {
|
|
863
|
+
clearInterval(this.heartbeat);
|
|
864
|
+
this.heartbeat = undefined;
|
|
865
|
+
}
|
|
866
|
+
this.clearFirstFrameTimer();
|
|
867
|
+
this.clearStreamHealthTimer();
|
|
868
|
+
}
|
|
869
|
+
|
|
756
870
|
private startShellCleanup(): Promise<BackgroundShellTerminationReport> {
|
|
757
871
|
return this.shellCleanup ??= terminateBackgroundShellsForSession(this.shellOwnerId);
|
|
758
872
|
}
|
|
759
873
|
|
|
760
874
|
async close(): Promise<void> {
|
|
761
875
|
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
876
|
+
if (this.turnEndedCloseTimer) clearTimeout(this.turnEndedCloseTimer);
|
|
762
877
|
this.clearPendingFinalize();
|
|
763
878
|
this.clearFirstFrameTimer();
|
|
879
|
+
this.clearStreamHealthTimer();
|
|
764
880
|
this.stream?.close();
|
|
765
881
|
this.session?.close();
|
|
766
882
|
this.http1Connection?.close();
|
|
@@ -776,6 +892,7 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
776
892
|
this.clearPendingFinalize();
|
|
777
893
|
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
778
894
|
this.clearFirstFrameTimer();
|
|
895
|
+
this.clearStreamHealthTimer();
|
|
779
896
|
if (this.http1Connection) {
|
|
780
897
|
this.http1Connection.close();
|
|
781
898
|
} else {
|
|
@@ -793,6 +910,46 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
793
910
|
void this.startShellCleanup().catch(() => { /* close() observes the same cleanup promise */ });
|
|
794
911
|
}
|
|
795
912
|
|
|
913
|
+
/**
|
|
914
|
+
* T03 (#1062): after the server sends `turnEnded`, the application turn is complete.
|
|
915
|
+
* A server that keeps the HTTP/2 stream open past this point cannot hold the turn
|
|
916
|
+
* hostage until a 300s bridge idle timeout. Close our side after a short grace so any
|
|
917
|
+
* trailing frames (late usage, checkpoint) still land before we release the socket.
|
|
918
|
+
*/
|
|
919
|
+
private closeAfterTurnEnded(): void {
|
|
920
|
+
if (this.turnEndedCloseTimer) return;
|
|
921
|
+
// The application turn is over: the T03 grace timer owns the socket from here.
|
|
922
|
+
// The T04 watchdog must disarm NOW, not at the grace close — a watchdog shorter
|
|
923
|
+
// than the grace would otherwise fail a completed turn.
|
|
924
|
+
this.clearStreamHealthTimer();
|
|
925
|
+
this.turnEndedCloseTimer = setTimeout(() => {
|
|
926
|
+
this.turnEndedCloseTimer = undefined;
|
|
927
|
+
// Only expectedClose (client-tool suspend cancel) blocks the close.
|
|
928
|
+
// emittedTerminal is intentionally NOT checked here: finalizeTurnEvents sets it
|
|
929
|
+
// synchronously during turnEnded mapping, ~500ms before this timer fires, so
|
|
930
|
+
// checking it would make the close unreachable on every real path (the exact
|
|
931
|
+
// scenario this PR exists to fix — senpi #1062).
|
|
932
|
+
if (this.expectedClose) return;
|
|
933
|
+
debugProviderDiagnostic("cursor", "turn-ended-close", {
|
|
934
|
+
committed: this.committed,
|
|
935
|
+
framesReceived: this.framesReceived,
|
|
936
|
+
});
|
|
937
|
+
this.expectedClose = true;
|
|
938
|
+
this.clearFirstFrameTimer();
|
|
939
|
+
this.clearStreamHealthTimer();
|
|
940
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
941
|
+
if (this.http1Connection) {
|
|
942
|
+
this.http1Connection.close();
|
|
943
|
+
} else {
|
|
944
|
+
try {
|
|
945
|
+
this.stream?.close();
|
|
946
|
+
} catch {
|
|
947
|
+
this.stream?.destroy();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}, TURN_ENDED_CLOSE_GRACE_MS);
|
|
951
|
+
}
|
|
952
|
+
|
|
796
953
|
private releaseBlobRequestScope(): void {
|
|
797
954
|
const scope = this.blobRequestScope;
|
|
798
955
|
if (!scope) return;
|
|
@@ -903,7 +1060,10 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
903
1060
|
const settler = createTerminalSettler({
|
|
904
1061
|
fail,
|
|
905
1062
|
finish,
|
|
906
|
-
clearTimer: () =>
|
|
1063
|
+
clearTimer: () => {
|
|
1064
|
+
this.clearFirstFrameTimer();
|
|
1065
|
+
this.clearStreamHealthTimer();
|
|
1066
|
+
},
|
|
907
1067
|
});
|
|
908
1068
|
const failAndClear = (error: Error) => {
|
|
909
1069
|
releaseBacklogLease();
|
|
@@ -1000,10 +1160,54 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
1000
1160
|
framesReceived: this.framesReceived,
|
|
1001
1161
|
elapsedMs: Date.now() - this.turnStartedAt,
|
|
1002
1162
|
} : { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt });
|
|
1003
|
-
if (endError)
|
|
1163
|
+
if (endError) {
|
|
1164
|
+
failAndClear(endError);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
// Connect's clean END_STREAM envelope is the protocol terminal. Cursor's RunSSE body can
|
|
1168
|
+
// remain open after this frame (or close through an AbortError), so waiting for HTTP EOF
|
|
1169
|
+
// strands an otherwise completed turn until the outer bridge stall watchdog fires.
|
|
1170
|
+
//
|
|
1171
|
+
// Earlier frames in this serialized frameWork chain have already run. Preserve their real
|
|
1172
|
+
// turnEnded terminal when present; otherwise finalize the clean protocol end once so open
|
|
1173
|
+
// tool calls still fail closed, a text-only turn receives its normal done event, and a
|
|
1174
|
+
// drained client-tool turn does not lose the pending terminal when protocol cleanup clears
|
|
1175
|
+
// its grace timer.
|
|
1176
|
+
const hasPendingClientToolFinalization = this.pendingFinalize !== undefined;
|
|
1177
|
+
if (
|
|
1178
|
+
!this.expectedClose
|
|
1179
|
+
&& !state.terminated
|
|
1180
|
+
&& !this.emittedTerminal
|
|
1181
|
+
&& (
|
|
1182
|
+
state.openToolCalls.size > 0
|
|
1183
|
+
|| this.sawAssistantText
|
|
1184
|
+
|| hasPendingClientToolFinalization
|
|
1185
|
+
)
|
|
1186
|
+
) {
|
|
1187
|
+
const terminal = hasPendingClientToolFinalization && state.openToolCalls.size === 0
|
|
1188
|
+
? finalizeAfterDrain(state)
|
|
1189
|
+
: finalizeTurnEvents(state);
|
|
1190
|
+
for (const event of terminal) push(event);
|
|
1191
|
+
}
|
|
1192
|
+
this.markProtocolComplete();
|
|
1193
|
+
releaseBacklogLease();
|
|
1194
|
+
settler.settleFinish();
|
|
1004
1195
|
return;
|
|
1005
1196
|
}
|
|
1006
|
-
|
|
1197
|
+
const decoded = fromBinary(AgentServerMessageSchema, frame.payload);
|
|
1198
|
+
// T04: every decoded frame refreshes the silence clock; only non-liveness frames
|
|
1199
|
+
// refresh the progress clock. First decoded frame arms the watchdog (the first-frame
|
|
1200
|
+
// timer owned everything before this point).
|
|
1201
|
+
const decodedUpdate = decoded.message.case === "interactionUpdate" ? decoded.message.value.message?.case : undefined;
|
|
1202
|
+
const livenessOnly = decodedUpdate === "heartbeat" || decoded.message.case === "conversationCheckpointUpdate";
|
|
1203
|
+
if (!this.streamHealthFail) {
|
|
1204
|
+
const now = Date.now();
|
|
1205
|
+
this.lastInboundFrameAt = now;
|
|
1206
|
+
this.lastMeaningfulFrameAt = now;
|
|
1207
|
+
this.streamHealthFail = failAndClear;
|
|
1208
|
+
}
|
|
1209
|
+
this.noteInboundFrame(livenessOnly);
|
|
1210
|
+
await this.handleServerMessage(decoded, state, push);
|
|
1007
1211
|
};
|
|
1008
1212
|
const drainPendingFrames = () => {
|
|
1009
1213
|
const availableSlots = CURSOR_MAX_PENDING_FRAMES - this.pendingTransportFrames;
|
|
@@ -1273,6 +1477,12 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
1273
1477
|
// A completion may carry only callId. Capture its ownership before mapping removes the open
|
|
1274
1478
|
// call, because the embedded-tool classifier cannot identify that valid compact frame.
|
|
1275
1479
|
const update = message.message.case === "interactionUpdate" ? message.message.value.message : undefined;
|
|
1480
|
+
if (update?.case === "turnEnded") {
|
|
1481
|
+
// T03: the application turn is complete. Close our side of HTTP/2 after a short
|
|
1482
|
+
// grace so a held-open server response cannot pin the turn to the bridge's idle
|
|
1483
|
+
// timeout (senpi #1062). finalizeTurnEvents already emitted done via the mapper.
|
|
1484
|
+
this.closeAfterTurnEnded();
|
|
1485
|
+
}
|
|
1276
1486
|
const completesOpenClientTool = update?.case === "toolCallCompleted"
|
|
1277
1487
|
&& state.openToolCalls.has(update.value.callId);
|
|
1278
1488
|
const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { create, toBinary } from "@bufbuild/protobuf";
|
|
2
2
|
import {
|
|
3
3
|
AgentClientMessageSchema,
|
|
4
|
+
ExecClientThrowSchema,
|
|
4
5
|
ExecClientControlMessageSchema,
|
|
5
6
|
ExecClientMessageSchema,
|
|
6
7
|
ExecClientStreamCloseSchema,
|
|
@@ -49,6 +50,22 @@ export function execStreamCloseBytes(execMsg: ExecServerMessage): Uint8Array {
|
|
|
49
50
|
});
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Exec-channel typed throw (`execClientControlMessage.throw`). senpi's contract (T05):
|
|
55
|
+
* a frame that cannot be answered at all must get an explicit error reply + stream-close
|
|
56
|
+
* so the server unblocks with a known failure, instead of waiting forever on silence.
|
|
57
|
+
*/
|
|
58
|
+
export function execThrowBytes(execMsg: ExecServerMessage, error: string): Uint8Array {
|
|
59
|
+
return clientBytes({
|
|
60
|
+
message: {
|
|
61
|
+
case: "execClientControlMessage",
|
|
62
|
+
value: create(ExecClientControlMessageSchema, {
|
|
63
|
+
message: { case: "throw", value: create(ExecClientThrowSchema, { id: execMsg.id, error }) },
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
export function errorText(err: unknown): string {
|
|
53
70
|
return err instanceof Error ? err.message : String(err);
|
|
54
71
|
}
|