@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.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-DkcRs1fL.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/cursor/cursor-errors.ts +65 -6
  6. package/src/adapters/cursor/discovery.ts +7 -2
  7. package/src/adapters/cursor/effort-map.ts +6 -0
  8. package/src/adapters/cursor/h2-pool.ts +123 -0
  9. package/src/adapters/cursor/live-models.ts +21 -26
  10. package/src/adapters/cursor/live-transport.ts +213 -3
  11. package/src/adapters/cursor/native-exec-common.ts +17 -0
  12. package/src/adapters/cursor/native-exec.ts +9 -4
  13. package/src/adapters/cursor/protobuf-events.ts +5 -1
  14. package/src/adapters/cursor/protobuf-request.ts +11 -4
  15. package/src/adapters/cursor/tool-definitions.ts +20 -0
  16. package/src/adapters/cursor/transport.ts +10 -0
  17. package/src/adapters/cursor.ts +23 -5
  18. package/src/adapters/google.ts +16 -3
  19. package/src/adapters/openai-responses.ts +66 -20
  20. package/src/adapters/xai-web-search.ts +185 -0
  21. package/src/cli/agent.ts +2 -1
  22. package/src/cli/dispatch.ts +2 -2
  23. package/src/cli/doctor.ts +89 -0
  24. package/src/cli/help.ts +2 -0
  25. package/src/cli/registry.ts +7 -2
  26. package/src/codex/auth-context.ts +41 -2
  27. package/src/codex/catalog/effort.ts +1 -1
  28. package/src/codex/catalog/parsing.ts +2 -0
  29. package/src/codex/catalog/provider-fetch.ts +20 -5
  30. package/src/codex/coordinator-doctor.ts +332 -0
  31. package/src/codex/inject-coordination.ts +39 -6
  32. package/src/codex/transition-state.ts +12 -12
  33. package/src/generated/compatibility-version.json +74 -50
  34. package/src/lib/errors.ts +8 -2
  35. package/src/oauth/cursor.ts +21 -0
  36. package/src/providers/cursor-pool.ts +72 -0
  37. package/src/providers/derive.ts +3 -0
  38. package/src/providers/fastwire.ts +12 -1
  39. package/src/providers/openai-sidecar.ts +1 -0
  40. package/src/providers/registry.ts +25 -0
  41. package/src/providers/service-tier.ts +22 -7
  42. package/src/responses/custom-tool-compat.ts +24 -8
  43. package/src/responses/namespace-tool-compat.ts +2 -3
  44. package/src/router.ts +3 -0
  45. package/src/server/chat-completions.ts +4 -0
  46. package/src/server/chat-native.ts +20 -0
  47. package/src/server/management/agent-settings-routes.ts +16 -5
  48. package/src/server/management/config-routes.ts +25 -5
  49. package/src/server/management/vision-sidecar-options.ts +54 -19
  50. package/src/server/responses/compact.ts +1 -2
  51. package/src/server/responses/core.ts +54 -13
  52. package/src/service.ts +122 -14
  53. package/src/types/config.ts +9 -3
  54. package/src/types/provider.ts +6 -0
  55. package/src/usage/cost.ts +52 -38
  56. package/src/usage/expected-prices.ts +79 -9
  57. package/src/vision/backends.ts +97 -0
  58. package/src/vision/eligibility.ts +43 -22
  59. package/src/vision/index.ts +73 -5
  60. package/src/vision/routed-describe.ts +175 -0
  61. package/gui/dist/assets/index-eBA05kYB.js +0 -102
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-eBA05kYB.js"></script>
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.30.0-preview.20260821",
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
- return isCursorRequestTooLargeDetail(lower)
162
- ? "Cursor resource limit exceeded"
163
- : "Cursor rate limit exceeded";
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
- // opus-4-7-fast: effort-suffix tiers unverified -> no tier picker; sent bare like live-only ids.
240
- { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K },
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
- const timer = setTimeout(() => {
216
- finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` });
217
- client.destroy();
218
- }, timeoutMs);
219
- const close = (value: CursorUsableModelsResult): void => {
220
- clearTimeout(timer);
221
- client.close();
222
- finish(value);
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
- let req: http2.ClientHttp2Stream;
228
- try {
229
- req = client.request({
230
- ":method": "POST",
231
- ":path": CURSOR_GET_USABLE_MODELS_PATH,
232
- ...cursorDiscoveryHeaders(opts),
233
- });
234
- } catch {
235
- return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" });
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: () => this.clearFirstFrameTimer(),
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) failAndClear(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
- await this.handleServerMessage(fromBinary(AgentServerMessageSchema, frame.payload), state, push);
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
  }