cursor-opencode-provider 0.2.3 → 0.2.5

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.
@@ -4,6 +4,7 @@ import { createCursorChecksumHeader } from "../protocol/checksum.js";
4
4
  import { getDeviceIds } from "../protocol/device-id.js";
5
5
  import { resolveClientVersion } from "../protocol/client-version.js";
6
6
  import { trace } from "../debug.js";
7
+ import { CursorLocalCancellationError, CursorProtocolError, CursorTransportError, cursorGrpcError, cursorHttpError, errorCode, CursorProviderError, } from "../errors.js";
7
8
  import http2 from "node:http2";
8
9
  const API_BASE = `https://${CURSOR_API_HOST}`;
9
10
  function resolveApiBaseURL(options) {
@@ -12,16 +13,21 @@ function resolveApiBaseURL(options) {
12
13
  trace("connect.ts module loaded");
13
14
  export function buildBaseHeaders(token, clientVersion, extra) {
14
15
  const { machineId, macMachineId } = getDeviceIds();
15
- return {
16
+ const headers = {
16
17
  authorization: `Bearer ${token}`,
17
18
  "connect-protocol-version": CONNECT_PROTOCOL_VERSION,
18
19
  "x-cursor-client-type": "cli",
19
20
  "x-cursor-client-version": clientVersion,
20
21
  "x-cursor-checksum": createCursorChecksumHeader(machineId, macMachineId),
21
22
  "x-ghost-mode": "true",
22
- "x-request-id": crypto.randomUUID(),
23
23
  ...extra,
24
24
  };
25
+ for (const key of Object.keys(headers)) {
26
+ if (key.toLowerCase() === "x-request-id")
27
+ delete headers[key];
28
+ }
29
+ headers["x-request-id"] = crypto.randomUUID();
30
+ return headers;
25
31
  }
26
32
  // ── Unary (AvailableModels) ──
27
33
  export async function unaryAvailableModels(token, options = {}) {
@@ -29,30 +35,40 @@ export async function unaryAvailableModels(token, options = {}) {
29
35
  const url = `${base}/aiserver.v1.AiService/AvailableModels`;
30
36
  const clientVersion = await resolveClientVersion();
31
37
  const headers = buildBaseHeaders(token, clientVersion, options.headers);
32
- const res = await fetch(url, {
33
- method: "POST",
34
- headers: {
35
- ...headers,
36
- "content-type": "application/json",
37
- accept: "application/json",
38
- },
39
- // AvailableModelsRequest flags (proto aiserver.v1). The IDE sets these
40
- // (modelConfigService.js useModelParameters, entry.js includeLongContextModels).
41
- // useModelParameters + useCloudAgentEffortModes return parameterized
42
- // variants (effort/context/fast). includeLongContextModels may populate
43
- // context_token_limit fields; when those stay empty we still derive limits
44
- // from each variant's `context` param in mapAvailableModelsResponse.
45
- body: JSON.stringify({
46
- includeLongContextModels: true,
47
- useModelParameters: true,
48
- useCloudAgentEffortModes: true,
49
- }),
50
- });
38
+ let res;
39
+ try {
40
+ res = await fetch(url, {
41
+ method: "POST",
42
+ headers: {
43
+ ...headers,
44
+ "content-type": "application/json",
45
+ accept: "application/json",
46
+ },
47
+ // Request parameterized effort/context/fast variants like Cursor IDE.
48
+ body: JSON.stringify({
49
+ includeLongContextModels: true,
50
+ useModelParameters: true,
51
+ useCloudAgentEffortModes: true,
52
+ }),
53
+ });
54
+ }
55
+ catch (cause) {
56
+ throw new CursorTransportError("AvailableModels network request failed", {
57
+ transient: true,
58
+ replaySafe: true,
59
+ code: errorCode(cause),
60
+ cause,
61
+ });
62
+ }
51
63
  if (!res.ok) {
52
- const text = await res.text().catch(() => "");
53
- throw new Error(`AvailableModels failed: ${res.status} ${res.statusText}${text ? ` - ${text.slice(0, 200)}` : ""}`);
64
+ throw await cursorHttpResponseError("AvailableModels failed:", res);
65
+ }
66
+ try {
67
+ return (await res.json());
68
+ }
69
+ catch (cause) {
70
+ throw new CursorProtocolError("AvailableModels returned malformed JSON", { cause });
54
71
  }
55
- return (await res.json());
56
72
  }
57
73
  // ── Unary (GetServerConfig) ──
58
74
  /**
@@ -104,46 +120,113 @@ export async function fetchAgentUrl(token, options = {}) {
104
120
  // Match Cursor CLI: GetServerConfig uses base headers only — session headers belong on Run.
105
121
  const headers = buildBaseHeaders(token, clientVersion);
106
122
  trace(`GetServerConfig POST ${url}`);
107
- const res = await fetch(url, {
108
- method: "POST",
109
- headers: {
110
- ...headers,
111
- "content-type": "application/json",
112
- accept: "application/json",
113
- },
114
- body: JSON.stringify({ telem_enabled: options.telemetryEnabled ?? false }),
115
- });
123
+ let res;
124
+ try {
125
+ res = await fetch(url, {
126
+ method: "POST",
127
+ headers: {
128
+ ...headers,
129
+ "content-type": "application/json",
130
+ accept: "application/json",
131
+ },
132
+ body: JSON.stringify({ telem_enabled: options.telemetryEnabled ?? false }),
133
+ });
134
+ }
135
+ catch (cause) {
136
+ throw new CursorTransportError("GetServerConfig network request failed", {
137
+ transient: true,
138
+ replaySafe: true,
139
+ code: errorCode(cause),
140
+ cause,
141
+ });
142
+ }
116
143
  if (!res.ok) {
117
- const text = await res.text().catch(() => "");
118
- throw new Error(`GetServerConfig failed: ${res.status} ${res.statusText}${text ? ` - ${text.slice(0, 200)}` : ""}`);
144
+ throw await cursorHttpResponseError("GetServerConfig failed:", res);
145
+ }
146
+ let body;
147
+ try {
148
+ body = (await res.json());
149
+ }
150
+ catch (cause) {
151
+ throw new CursorProtocolError("GetServerConfig returned malformed JSON", { cause });
119
152
  }
120
- const body = (await res.json());
121
153
  const cfg = body.agentUrlConfig;
122
154
  const raw = cfg?.agentnUrl || cfg?.agentUrl;
123
155
  const normalized = normalizeAgentRunOrigin(raw);
124
156
  trace(`GetServerConfig reply: agentnUrl=${cfg?.agentnUrl ?? "<missing>"} ` +
125
157
  `agentUrl=${cfg?.agentUrl ?? "<missing>"} → ${normalized ?? "<invalid>"}`);
126
158
  if (!normalized) {
127
- throw new Error(raw
159
+ throw new CursorProtocolError(raw
128
160
  ? "GetServerConfig returned an invalid Cursor agent URL"
129
161
  : "GetServerConfig response missing agentUrlConfig.agentnUrl");
130
162
  }
131
163
  return normalized;
132
164
  }
165
+ export class CursorRunInterruptedError extends CursorTransportError {
166
+ constructor(message = "Cursor Run ended before turn_ended", options) {
167
+ super(message, {
168
+ transient: true,
169
+ replaySafe: true,
170
+ cause: options?.cause,
171
+ });
172
+ this.name = "CursorRunInterruptedError";
173
+ }
174
+ }
175
+ export function cursorRunTerminationError(input) {
176
+ const headers = input.responseHeaders ?? {};
177
+ const trailers = input.responseTrailers ?? {};
178
+ if (input.streamError) {
179
+ return new CursorRunInterruptedError(`Cursor Run transport interrupted: ${input.streamError.message}`, { cause: input.streamError });
180
+ }
181
+ if (input.responseStatus !== 0 && input.responseStatus !== 200) {
182
+ return cursorRunHttpError(input.responseStatus, headers);
183
+ }
184
+ const grpcStatus = trailers["grpc-status"] ?? headers["grpc-status"];
185
+ if (grpcStatus !== undefined && String(grpcStatus) !== "0") {
186
+ return cursorRunGrpcError(String(grpcStatus), headers, trailers);
187
+ }
188
+ return new CursorRunInterruptedError();
189
+ }
190
+ function withErrorMessageSuffix(error, suffix) {
191
+ if (suffix)
192
+ error.message += suffix;
193
+ return error;
194
+ }
195
+ async function cursorHttpResponseError(operation, res) {
196
+ const text = await res.text().catch(() => "");
197
+ return withErrorMessageSuffix(cursorHttpError(operation, res.status), `${res.statusText ? ` ${res.statusText}` : ""}${text ? ` - ${text.slice(0, 200)}` : ""}`);
198
+ }
199
+ function cursorRunHttpError(statusCode, headers) {
200
+ return withErrorMessageSuffix(cursorHttpError("Cursor Run failed by remote:", statusCode), ` ${JSON.stringify(stripPseudo(headers))}`);
201
+ }
202
+ function cursorRunGrpcError(grpcStatus, headers, trailers) {
203
+ const message = trailers["grpc-message"] ?? headers["grpc-message"];
204
+ return withErrorMessageSuffix(cursorGrpcError("Cursor Run failed by remote:", grpcStatus), message === undefined ? "" : `: ${message}`);
205
+ }
133
206
  // Cache http2 sessions keyed by origin so a custom baseURL never reuses a
134
207
  // connection opened to the default agent host — and vice versa.
135
208
  const _http2Sessions = new Map();
136
- // In-flight connects for the same origin share one Promise (avoid parallel races).
209
+ const _http2SessionCreatedAt = new WeakMap();
210
+ const _http2SessionListenerCleanup = new WeakMap();
211
+ // Validation and connect work for the same origin shares one Promise.
137
212
  const _http2Connecting = new Map();
138
213
  // Bound the time we'll wait for the initial connect. Without this, a dead or
139
214
  // unreachable host (DNS failure, network partition) hangs the provider forever
140
215
  // because the 'connect' / 'error' event may never fire.
141
216
  const CONNECT_TIMEOUT_MS = 15_000;
217
+ const DEFAULT_SESSION_PING_TIMEOUT_MS = 5_000;
218
+ const DEFAULT_READ_IDLE_MS = 120_000;
219
+ const WRITE_DRAIN_TIMEOUT_CODE = "CURSOR_WRITE_DRAIN_TIMEOUT";
220
+ const MAX_TIMER_MS = 2_147_483_647;
221
+ export const HTTP2_SESSION_MAX_AGE_MS = 15 * 60_000;
222
+ export function shouldReuseHttp2Session(state, createdAt, now = Date.now()) {
223
+ return !state.destroyed && !state.closed && now - createdAt < HTTP2_SESSION_MAX_AGE_MS;
224
+ }
142
225
  /** Resolve the HTTP/2 connect origin for a Run stream (exported for tests). */
143
226
  export function resolveAgentOrigin(baseURL) {
144
227
  const origin = normalizeAgentRunOrigin(baseURL);
145
228
  if (!origin) {
146
- throw new Error("Cursor Run stream requires an allowlisted Cursor agent base URL");
229
+ throw new CursorProtocolError("Cursor Run stream requires an allowlisted Cursor agent base URL");
147
230
  }
148
231
  return origin;
149
232
  }
@@ -151,62 +234,250 @@ function dropSession(origin, session) {
151
234
  if (_http2Sessions.get(origin) === session)
152
235
  _http2Sessions.delete(origin);
153
236
  }
154
- function getSession(baseURL) {
155
- const origin = resolveAgentOrigin(baseURL);
156
- const existing = _http2Sessions.get(origin);
157
- if (existing && !existing.destroyed && !existing.closed) {
158
- return Promise.resolve(existing);
237
+ /** Test cleanup for local HTTP/2 fixtures; production sessions stay process-cached. */
238
+ export function closeCachedHttp2SessionsForTests() {
239
+ for (const session of _http2Sessions.values()) {
240
+ _http2SessionListenerCleanup.get(session)?.();
241
+ try {
242
+ session.destroy();
243
+ }
244
+ catch { /* already closed */ }
159
245
  }
160
- const inflight = _http2Connecting.get(origin);
161
- if (inflight)
162
- return inflight;
163
- const promise = new Promise((resolve, reject) => {
246
+ _http2Sessions.clear();
247
+ _http2Connecting.clear();
248
+ }
249
+ function timeoutMs(name, value, fallback) {
250
+ const resolved = value ?? fallback;
251
+ if (!Number.isSafeInteger(resolved) || resolved <= 0 || resolved > MAX_TIMER_MS) {
252
+ throw new CursorProtocolError(`${name} must be a positive integer no greater than ${MAX_TIMER_MS}`);
253
+ }
254
+ return resolved;
255
+ }
256
+ function toTransportError(value, fallback) {
257
+ if (value instanceof CursorTransportError)
258
+ return value;
259
+ return new CursorTransportError(fallback, {
260
+ transient: true,
261
+ replaySafe: true,
262
+ code: errorCode(value),
263
+ cause: value,
264
+ });
265
+ }
266
+ function installSessionInvalidation(origin, session) {
267
+ const socket = session.socket;
268
+ let cleaned = false;
269
+ let removeSocketListeners;
270
+ const cleanup = () => {
271
+ if (cleaned)
272
+ return;
273
+ cleaned = true;
274
+ session.removeListener("close", onSessionClose);
275
+ session.removeListener("goaway", onSessionGoaway);
276
+ session.removeListener("error", onSessionError);
277
+ removeSocketListeners?.();
278
+ removeSocketListeners = undefined;
279
+ _http2SessionListenerCleanup.delete(session);
280
+ };
281
+ const onSessionClose = () => {
282
+ trace(`h2 session closed: origin=${origin}`);
283
+ dropSession(origin, session);
284
+ cleanup();
285
+ };
286
+ const onSessionGoaway = (errorCode, lastStreamID) => {
287
+ trace(`h2 session GOAWAY: origin=${origin} errorCode=${errorCode} lastStreamID=${lastStreamID}`);
288
+ // Existing streams may drain, but future Runs must use a fresh session.
289
+ dropSession(origin, session);
290
+ };
291
+ const onSessionError = (error) => {
292
+ trace(`h2 session error: origin=${origin} err=${error.message}`);
293
+ dropSession(origin, session);
294
+ };
295
+ const onSocketEnd = () => {
296
+ trace(`h2 socket ended: origin=${origin}`);
297
+ dropSession(origin, session);
298
+ };
299
+ const onSocketClose = () => {
300
+ trace(`h2 socket closed: origin=${origin}`);
301
+ dropSession(origin, session);
302
+ };
303
+ session.once("close", onSessionClose);
304
+ session.on("goaway", onSessionGoaway);
305
+ // Keep an error listener until close so teardown errors cannot become an
306
+ // unhandled EventEmitter error.
307
+ session.on("error", onSessionError);
308
+ if (socket) {
309
+ socket.once("end", onSocketEnd);
310
+ socket.once("close", onSocketClose);
311
+ // Http2Session.socket is a lifecycle-bound proxy. Looking up methods after
312
+ // detach throws ERR_HTTP2_SOCKET_UNBOUND, so bind removers while attached.
313
+ const removeEndListener = socket.removeListener.bind(socket, "end", onSocketEnd);
314
+ const removeCloseListener = socket.removeListener.bind(socket, "close", onSocketClose);
315
+ removeSocketListeners = () => {
316
+ try {
317
+ removeEndListener();
318
+ }
319
+ catch { /* teardown is best-effort */ }
320
+ try {
321
+ removeCloseListener();
322
+ }
323
+ catch { /* teardown is best-effort */ }
324
+ };
325
+ }
326
+ _http2SessionListenerCleanup.set(session, cleanup);
327
+ }
328
+ /** Node-runtime regression hook; production callers use getSession(). */
329
+ export function installSessionInvalidationForTests(origin, session) {
330
+ installSessionInvalidation(origin, session);
331
+ }
332
+ function invalidateSession(origin, session) {
333
+ dropSession(origin, session);
334
+ _http2SessionListenerCleanup.get(session)?.();
335
+ try {
336
+ session.destroy();
337
+ }
338
+ catch { /* already closed */ }
339
+ }
340
+ function validateCachedSession(session, pingTimeoutMs) {
341
+ if (session.destroyed || session.closed) {
342
+ return Promise.reject(new CursorTransportError("Cursor HTTP/2 cached session is already closed"));
343
+ }
344
+ return new Promise((resolve, reject) => {
345
+ let settled = false;
346
+ const finish = (error) => {
347
+ if (settled)
348
+ return;
349
+ settled = true;
350
+ clearTimeout(timer);
351
+ session.removeListener("close", onClosed);
352
+ session.removeListener("goaway", onGoaway);
353
+ session.removeListener("error", onError);
354
+ if (error)
355
+ reject(error);
356
+ else
357
+ resolve();
358
+ };
359
+ const onClosed = () => finish(new CursorTransportError("Cursor HTTP/2 cached session closed during ping"));
360
+ const onGoaway = () => finish(new CursorTransportError("Cursor HTTP/2 cached session received GOAWAY during ping"));
361
+ const onError = (error) => finish(toTransportError(error, "Cursor HTTP/2 cached session ping failed"));
362
+ const timer = setTimeout(() => {
363
+ finish(new CursorTransportError(`Cursor HTTP/2 cached session ping timed out after ${pingTimeoutMs}ms`));
364
+ }, pingTimeoutMs);
365
+ timer.unref?.();
366
+ session.once("close", onClosed);
367
+ session.once("goaway", onGoaway);
368
+ session.once("error", onError);
369
+ try {
370
+ const accepted = session.ping((error) => {
371
+ if (error)
372
+ finish(toTransportError(error, "Cursor HTTP/2 cached session ping failed"));
373
+ else
374
+ finish();
375
+ });
376
+ if (!accepted)
377
+ finish(new CursorTransportError("Cursor HTTP/2 cached session refused a health-check ping"));
378
+ }
379
+ catch (error) {
380
+ finish(toTransportError(error, "Cursor HTTP/2 cached session ping failed"));
381
+ }
382
+ });
383
+ }
384
+ function connectSession(origin) {
385
+ return new Promise((resolve, reject) => {
164
386
  const session = http2.connect(origin);
387
+ let settled = false;
165
388
  const cleanup = () => {
166
389
  clearTimeout(timer);
167
390
  session.removeListener("error", onError);
391
+ session.removeListener("close", onClose);
168
392
  session.removeListener("connect", onConnect);
169
393
  };
170
- const onError = (err) => {
394
+ const fail = (error) => {
395
+ if (settled)
396
+ return;
397
+ settled = true;
171
398
  cleanup();
172
- _http2Connecting.delete(origin);
173
399
  dropSession(origin, session);
174
400
  try {
175
401
  session.destroy();
176
402
  }
177
403
  catch { /* ignore */ }
178
- reject(err);
404
+ reject(error);
179
405
  };
406
+ const onError = (error) => fail(toTransportError(error, "Cursor HTTP/2 connection failed"));
407
+ const onClose = () => fail(new CursorTransportError(`HTTP/2 connection to ${origin} closed before connecting`));
180
408
  const onConnect = () => {
409
+ if (settled)
410
+ return;
411
+ settled = true;
181
412
  cleanup();
182
- _http2Connecting.delete(origin);
183
- // Future post-connect errors (GOAWAY, RST_STREAM) must invalidate the
184
- // cache; otherwise subsequent getSession() calls reuse a degraded session.
185
- const onCloseOrError = () => dropSession(origin, session);
186
- session.once("close", onCloseOrError);
187
- session.on("error", onCloseOrError);
413
+ installSessionInvalidation(origin, session);
414
+ if (session.destroyed || session.closed) {
415
+ const error = new CursorTransportError(`HTTP/2 connection to ${origin} closed while connecting`);
416
+ invalidateSession(origin, session);
417
+ reject(error);
418
+ return;
419
+ }
420
+ _http2SessionCreatedAt.set(session, Date.now());
188
421
  _http2Sessions.set(origin, session);
422
+ trace(`h2 session connected: origin=${origin}`);
189
423
  resolve(session);
190
424
  };
191
425
  const timer = setTimeout(() => {
192
- cleanup();
193
- _http2Connecting.delete(origin);
194
- dropSession(origin, session);
195
- try {
196
- session.destroy();
197
- }
198
- catch { /* ignore */ }
199
- reject(new Error(`HTTP/2 connect to ${origin} timed out after ${CONNECT_TIMEOUT_MS}ms`));
426
+ fail(new CursorTransportError(`HTTP/2 connect to ${origin} timed out after ${CONNECT_TIMEOUT_MS}ms`));
200
427
  }, CONNECT_TIMEOUT_MS);
428
+ timer.unref?.();
201
429
  session.on("error", onError);
202
- session.on("connect", onConnect);
430
+ session.once("close", onClose);
431
+ session.once("connect", onConnect);
203
432
  });
433
+ }
434
+ export function getSession(baseURL, options = {}) {
435
+ const origin = resolveAgentOrigin(baseURL);
436
+ const pingTimeoutMs = timeoutMs("Cursor provider pingTimeoutMs", options.pingTimeoutMs, DEFAULT_SESSION_PING_TIMEOUT_MS);
437
+ const inflight = _http2Connecting.get(origin);
438
+ if (inflight)
439
+ return inflight;
440
+ const promise = (async () => {
441
+ const existing = _http2Sessions.get(origin);
442
+ if (existing) {
443
+ const createdAt = _http2SessionCreatedAt.get(existing) ?? 0;
444
+ if (!shouldReuseHttp2Session(existing, createdAt)) {
445
+ trace(`h2 session rotate: origin=${origin} ageMs=${Math.max(0, Date.now() - createdAt)}`);
446
+ dropSession(origin, existing);
447
+ try {
448
+ existing.close();
449
+ }
450
+ catch { /* already closed */ }
451
+ }
452
+ else {
453
+ try {
454
+ await validateCachedSession(existing, pingTimeoutMs);
455
+ if (_http2Sessions.get(origin) === existing && !existing.destroyed && !existing.closed) {
456
+ trace(`h2 cached session ping ok: origin=${origin}`);
457
+ return existing;
458
+ }
459
+ }
460
+ catch (error) {
461
+ trace(`h2 cached session ping failed: origin=${origin} err=${error.message}`);
462
+ invalidateSession(origin, existing);
463
+ }
464
+ }
465
+ }
466
+ return connectSession(origin);
467
+ })();
204
468
  _http2Connecting.set(origin, promise);
469
+ const cleanup = () => {
470
+ if (_http2Connecting.get(origin) === promise)
471
+ _http2Connecting.delete(origin);
472
+ };
473
+ promise.then(cleanup, cleanup);
205
474
  return promise;
206
475
  }
207
476
  export async function bidiRunStream(token, options) {
477
+ const origin = resolveAgentOrigin(options.baseURL);
478
+ const readIdleMs = timeoutMs("Cursor provider readIdleMs", options.readIdleMs, DEFAULT_READ_IDLE_MS);
208
479
  const [session, clientVersion] = await Promise.all([
209
- getSession(options.baseURL),
480
+ getSession(options.baseURL, { pingTimeoutMs: options.pingTimeoutMs }),
210
481
  resolveClientVersion(),
211
482
  ]);
212
483
  const headers = {
@@ -225,10 +496,179 @@ export async function bidiRunStream(token, options) {
225
496
  endStream: false,
226
497
  });
227
498
  let writable = true;
228
- let closed = false;
499
+ let locallyClosed = false;
500
+ let remotelyClosed = false;
501
+ let backpressured = false;
229
502
  let responseStatus = 0;
230
503
  let responseHeaders = {};
231
- let streamError = null;
504
+ let responseTrailers = {};
505
+ let rawStreamError;
506
+ let streamFailure = null;
507
+ let terminalEvent;
508
+ let terminalSettlementScheduled = false;
509
+ const terminalListeners = new Set();
510
+ const inboundFrames = [];
511
+ const pendingChunks = [];
512
+ let inboundEnded = false;
513
+ let inboundFailure;
514
+ let inboundWaiter;
515
+ let readIdleTimer;
516
+ let inboundReaderStarted = false;
517
+ const abortSignal = options.signal;
518
+ let abortHandler;
519
+ const observedFailure = () => {
520
+ if (streamFailure)
521
+ return streamFailure;
522
+ if (responseStatus !== 0 && responseStatus !== 200) {
523
+ streamFailure = cursorRunHttpError(responseStatus, responseHeaders);
524
+ return streamFailure;
525
+ }
526
+ const grpcStatus = responseTrailers["grpc-status"] ?? responseHeaders["grpc-status"];
527
+ if (grpcStatus !== undefined && String(grpcStatus) !== "0") {
528
+ streamFailure = cursorRunGrpcError(String(grpcStatus), responseHeaders, responseTrailers);
529
+ return streamFailure;
530
+ }
531
+ if (rawStreamError) {
532
+ streamFailure = toTransportError(rawStreamError, "Cursor Run transport interrupted");
533
+ return streamFailure;
534
+ }
535
+ return undefined;
536
+ };
537
+ const settleTerminal = (event) => {
538
+ if (terminalEvent)
539
+ return;
540
+ terminalEvent = event;
541
+ for (const listener of terminalListeners) {
542
+ try {
543
+ listener(event);
544
+ }
545
+ catch { /* observers cannot destabilize teardown */ }
546
+ }
547
+ terminalListeners.clear();
548
+ };
549
+ const clearReadIdleTimer = () => {
550
+ if (readIdleTimer)
551
+ clearTimeout(readIdleTimer);
552
+ readIdleTimer = undefined;
553
+ };
554
+ const cleanupTerminalResources = () => {
555
+ clearReadIdleTimer();
556
+ if (abortSignal && abortHandler) {
557
+ abortSignal.removeEventListener("abort", abortHandler);
558
+ abortHandler = undefined;
559
+ }
560
+ };
561
+ const finishInbound = (error) => {
562
+ if (error) {
563
+ inboundFailure = error;
564
+ pendingChunks.length = 0;
565
+ }
566
+ else if (!inboundFailure) {
567
+ inboundEnded = true;
568
+ }
569
+ const waiter = inboundWaiter;
570
+ inboundWaiter = undefined;
571
+ if (!waiter)
572
+ return;
573
+ if (inboundFailure)
574
+ waiter.reject(inboundFailure);
575
+ else
576
+ waiter.resolve(undefined);
577
+ };
578
+ const stopInboundReader = () => {
579
+ cleanupTerminalResources();
580
+ if (inboundReaderStarted) {
581
+ stream.removeListener("data", onInboundChunk);
582
+ inboundReaderStarted = false;
583
+ }
584
+ };
585
+ const nextInboundFrame = () => {
586
+ const queued = inboundFrames.shift();
587
+ if (queued)
588
+ return Promise.resolve(queued);
589
+ if (inboundFailure)
590
+ return Promise.reject(inboundFailure);
591
+ if (inboundEnded)
592
+ return Promise.resolve(undefined);
593
+ return new Promise((resolve, reject) => {
594
+ inboundWaiter = { resolve, reject };
595
+ });
596
+ };
597
+ const enqueueFrame = (frame) => {
598
+ const waiter = inboundWaiter;
599
+ inboundWaiter = undefined;
600
+ if (waiter)
601
+ waiter.resolve(frame);
602
+ else
603
+ inboundFrames.push(frame);
604
+ };
605
+ const settleObservedTerminal = () => {
606
+ terminalSettlementScheduled = false;
607
+ if (terminalEvent)
608
+ return;
609
+ if (locallyClosed) {
610
+ finishInbound();
611
+ stopInboundReader();
612
+ settleTerminal({ kind: "local-close" });
613
+ return;
614
+ }
615
+ const failure = observedFailure();
616
+ finishInbound(failure);
617
+ stopInboundReader();
618
+ settleTerminal(failure
619
+ ? { kind: "remote-error", error: failure }
620
+ : { kind: "remote-clean-close" });
621
+ };
622
+ const scheduleTerminalSettlement = () => {
623
+ if (terminalEvent || terminalSettlementScheduled)
624
+ return;
625
+ terminalSettlementScheduled = true;
626
+ // HTTP/2 often emits end/aborted immediately before error/close. One turn
627
+ // lets the strongest terminal metadata win.
628
+ setImmediate(settleObservedTerminal);
629
+ };
630
+ const onReadIdleTimeout = () => {
631
+ readIdleTimer = undefined;
632
+ if (locallyClosed || remotelyClosed || streamFailure)
633
+ return;
634
+ streamFailure = new CursorTransportError(`Cursor Run stream read-idle timeout after ${readIdleMs}ms — connection presumed dead`, { transient: true, replaySafe: true, code: "CURSOR_READ_IDLE_TIMEOUT" });
635
+ writable = false;
636
+ finishInbound(streamFailure);
637
+ stopInboundReader();
638
+ settleTerminal({ kind: "remote-error", error: streamFailure });
639
+ try {
640
+ stream.destroy(streamFailure);
641
+ }
642
+ catch { /* already closing */ }
643
+ };
644
+ const armReadIdleWatchdog = () => {
645
+ clearReadIdleTimer();
646
+ if (locallyClosed || remotelyClosed || streamFailure)
647
+ return;
648
+ readIdleTimer = setTimeout(onReadIdleTimeout, readIdleMs);
649
+ readIdleTimer.unref?.();
650
+ };
651
+ const onInboundChunk = (chunk) => {
652
+ armReadIdleWatchdog();
653
+ if (inboundEnded || inboundFailure)
654
+ return;
655
+ pendingChunks.push(new Uint8Array(chunk));
656
+ const merged = mergeBuffers(pendingChunks);
657
+ const parsed = Array.from(streamFrames(merged));
658
+ const consumed = parsed.reduce((sum, frame) => sum + 5 + frame.payload.length, 0);
659
+ pendingChunks.length = 0;
660
+ if (consumed < merged.length)
661
+ pendingChunks.push(merged.subarray(consumed));
662
+ for (const frame of parsed)
663
+ enqueueFrame(frame);
664
+ };
665
+ const startInboundReader = () => {
666
+ if (inboundReaderStarted || locallyClosed || remotelyClosed || streamFailure)
667
+ return;
668
+ inboundReaderStarted = true;
669
+ stream.on("data", onInboundChunk);
670
+ armReadIdleWatchdog();
671
+ };
232
672
  // Capture the HTTP/2 response status/headers and any stream-level error.
233
673
  // Without this, a non-200 or RST_STREAM surfaces as a silent clean end
234
674
  // (frames() just stops) — which looks exactly like "no response, no error".
@@ -237,78 +677,202 @@ export async function bidiRunStream(token, options) {
237
677
  responseStatus = h[":status"] !== undefined ? Number(h[":status"]) : 0;
238
678
  trace(`h2 response: status=${responseStatus} headers=${JSON.stringify(stripPseudo(h))}`);
239
679
  });
680
+ stream.on("trailers", (h) => {
681
+ responseTrailers = h;
682
+ trace(`h2 trailers: ${JSON.stringify(stripPseudo(h))}`);
683
+ });
240
684
  stream.on("error", (err) => {
241
- streamError = err;
685
+ rawStreamError = err;
686
+ writable = false;
242
687
  trace(`h2 stream error: ${err?.name}: ${err?.message}`);
688
+ scheduleTerminalSettlement();
243
689
  });
244
- stream.on("close", () => trace(`h2 stream closed (status=${responseStatus}, err=${streamError?.message ?? "none"})`));
245
- if (options.signal) {
246
- options.signal.addEventListener("abort", () => {
247
- if (!closed) {
248
- closed = true;
690
+ stream.on("aborted", () => {
691
+ writable = false;
692
+ rawStreamError ??= new CursorTransportError("Cursor Run stream aborted by remote", {
693
+ transient: true,
694
+ replaySafe: true,
695
+ rstCode: stream.rstCode,
696
+ code: "ERR_HTTP2_STREAM_CANCEL",
697
+ });
698
+ scheduleTerminalSettlement();
699
+ });
700
+ stream.on("end", () => {
701
+ writable = false;
702
+ scheduleTerminalSettlement();
703
+ });
704
+ stream.on("close", () => {
705
+ writable = false;
706
+ remotelyClosed = !locallyClosed;
707
+ if (remotelyClosed) {
708
+ dropSession(origin, session);
709
+ // Stop assigning sibling Runs to a connection that remotely lost one
710
+ // of its streams. close() drains existing streams without destroying
711
+ // them; the next Run opens a fresh HTTP/2 session.
712
+ try {
713
+ session.close();
714
+ }
715
+ catch { /* already closed */ }
716
+ }
717
+ trace(`h2 stream closed (status=${responseStatus}, local=${locallyClosed}, ` +
718
+ `err=${rawStreamError instanceof Error ? rawStreamError.message : "none"})`);
719
+ scheduleTerminalSettlement();
720
+ });
721
+ if (abortSignal) {
722
+ abortHandler = () => {
723
+ if (!locallyClosed) {
724
+ locallyClosed = true;
249
725
  writable = false;
726
+ inboundFrames.length = 0;
727
+ pendingChunks.length = 0;
728
+ finishInbound();
729
+ stopInboundReader();
730
+ settleTerminal({ kind: "local-close" });
250
731
  stream.close();
251
732
  }
252
- }, { once: true });
733
+ };
734
+ abortSignal.addEventListener("abort", abortHandler, { once: true });
735
+ if (abortSignal.aborted)
736
+ abortHandler();
253
737
  }
738
+ startInboundReader();
254
739
  return {
255
740
  write(msg) {
256
- if (!writable)
257
- throw new Error("Stream not writable");
741
+ if (!writable || remotelyClosed || stream.closed || stream.destroyed) {
742
+ if (locallyClosed) {
743
+ throw new CursorLocalCancellationError("Cursor Run stream is closed locally");
744
+ }
745
+ throw observedFailure() ?? new CursorRunInterruptedError("Cursor Run stream is no longer writable");
746
+ }
258
747
  const frame = encodeFrame(0x00, msg);
259
- stream.write(frame);
748
+ try {
749
+ const accepted = stream.write(frame);
750
+ backpressured = !accepted;
751
+ return accepted;
752
+ }
753
+ catch (cause) {
754
+ rawStreamError = cause;
755
+ streamFailure = toTransportError(cause, "Cursor Run stream write failed");
756
+ streamFailure.replaySafe = false;
757
+ writable = false;
758
+ scheduleTerminalSettlement();
759
+ throw streamFailure;
760
+ }
761
+ },
762
+ waitForDrain(timeout) {
763
+ if (!backpressured)
764
+ return Promise.resolve();
765
+ const drainTimeoutMs = timeoutMs("Cursor write drain timeout", timeout, timeout);
766
+ return new Promise((resolve, reject) => {
767
+ let settled = false;
768
+ const finish = (error) => {
769
+ if (settled)
770
+ return;
771
+ settled = true;
772
+ clearTimeout(timer);
773
+ stream.removeListener("drain", onDrain);
774
+ stream.removeListener("error", onError);
775
+ stream.removeListener("close", onClose);
776
+ if (error)
777
+ reject(error);
778
+ else
779
+ resolve();
780
+ };
781
+ const onDrain = () => {
782
+ backpressured = false;
783
+ finish();
784
+ };
785
+ const onError = (cause) => {
786
+ streamFailure ??= toTransportError(cause, "Cursor Run stream drain failed");
787
+ streamFailure.replaySafe = false;
788
+ finish(streamFailure);
789
+ };
790
+ const onClose = () => {
791
+ finish(locallyClosed
792
+ ? new CursorLocalCancellationError("Cursor Run stream closed locally during drain")
793
+ : observedFailure() ?? new CursorRunInterruptedError("Cursor Run stream closed before drain"));
794
+ };
795
+ const timer = setTimeout(() => {
796
+ streamFailure ??= new CursorTransportError(`Cursor Run stream backpressure did not drain after ${drainTimeoutMs}ms`, { transient: false, replaySafe: false, code: WRITE_DRAIN_TIMEOUT_CODE });
797
+ finish(streamFailure);
798
+ try {
799
+ stream.destroy(streamFailure);
800
+ }
801
+ catch { /* already closing */ }
802
+ }, drainTimeoutMs);
803
+ timer.unref?.();
804
+ stream.once("drain", onDrain);
805
+ stream.once("error", onError);
806
+ stream.once("close", onClose);
807
+ });
260
808
  },
261
809
  end() {
810
+ locallyClosed = true;
262
811
  writable = false;
812
+ inboundFrames.length = 0;
813
+ pendingChunks.length = 0;
814
+ finishInbound();
815
+ stopInboundReader();
816
+ settleTerminal({ kind: "local-close" });
263
817
  stream.end();
264
818
  },
265
819
  async *frames() {
266
- const buffer = [];
267
- for await (const chunk of stream) {
268
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
269
- if (closed)
270
- return;
271
- buffer.push(new Uint8Array(buf));
272
- // Try to parse frames from accumulated data
273
- const merged = mergeBuffers(buffer);
274
- const parsed = Array.from(streamFrames(merged));
275
- if (parsed.length > 0) {
276
- const consumed = parsed.reduce((sum, f) => sum + 5 + f.payload.length, 0);
277
- // Only clear if we fully consumed all pending data
278
- if (consumed === merged.length) {
279
- buffer.length = 0;
280
- }
281
- else {
282
- buffer.length = 0;
283
- buffer.push(merged.subarray(consumed));
284
- }
285
- for (const frame of parsed) {
286
- trace(`frame yield: flags=0x${frame.flags.toString(16)} payload=${frame.payload.length}B`);
287
- yield frame;
288
- }
820
+ try {
821
+ while (true) {
822
+ const frame = await nextInboundFrame();
823
+ if (!frame)
824
+ break;
825
+ trace(`frame yield: flags=0x${frame.flags.toString(16)} payload=${frame.payload.length}B`);
826
+ yield frame;
289
827
  }
828
+ remotelyClosed = !locallyClosed;
829
+ if (locallyClosed)
830
+ return;
831
+ // A clean HTTP/2 end before Cursor's turn_ended is still an interrupted
832
+ // Run at the provider layer.
833
+ throw observedFailure() ?? cursorRunTerminationError({
834
+ responseStatus,
835
+ responseHeaders,
836
+ responseTrailers,
837
+ });
290
838
  }
291
- closed = true;
292
- // Surface connection-level failures instead of ending silently. A
293
- // non-200, a Connect error in the trailers, or an RST_STREAM would
294
- // otherwise look like an empty successful response.
295
- if (streamError)
296
- throw streamError;
297
- if (responseStatus !== 0 && responseStatus !== 200) {
298
- throw new Error(`Cursor Run HTTP ${responseStatus} ${JSON.stringify(stripPseudo(responseHeaders))}`);
839
+ catch (error) {
840
+ if (locallyClosed)
841
+ return;
842
+ if (error instanceof CursorProviderError)
843
+ throw error;
844
+ throw toTransportError(error, `Cursor Run transport interrupted: ${error instanceof Error ? error.message : "unknown error"}`);
299
845
  }
300
- const grpcStatus = responseHeaders["grpc-status"];
301
- if (grpcStatus !== undefined && String(grpcStatus) !== "0") {
302
- throw new Error(`Cursor Run gRPC status ${grpcStatus}: ${responseHeaders["grpc-message"] ?? ""}`.trim());
846
+ finally {
847
+ stopInboundReader();
303
848
  }
304
849
  },
305
850
  destroy() {
306
- if (!closed) {
307
- closed = true;
851
+ if (!locallyClosed) {
852
+ locallyClosed = true;
308
853
  writable = false;
854
+ inboundFrames.length = 0;
855
+ pendingChunks.length = 0;
856
+ finishInbound();
857
+ stopInboundReader();
858
+ settleTerminal({ kind: "local-close" });
309
859
  stream.close();
310
860
  }
311
861
  },
862
+ isClosed() {
863
+ return remotelyClosed || locallyClosed || stream.closed || stream.destroyed;
864
+ },
865
+ onTerminal(listener) {
866
+ if (terminalEvent) {
867
+ try {
868
+ listener(terminalEvent);
869
+ }
870
+ catch { /* observers cannot destabilize teardown */ }
871
+ return () => { };
872
+ }
873
+ terminalListeners.add(listener);
874
+ return () => terminalListeners.delete(listener);
875
+ },
312
876
  };
313
877
  }
314
878
  function mergeBuffers(buffers) {