@gajae-code/ai 0.16.7 → 0.17.1

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 (58) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/dist/types/auth-storage.d.ts +23 -4
  3. package/dist/types/models.d.ts +14 -0
  4. package/dist/types/provider-models/special.d.ts +12 -0
  5. package/dist/types/providers/anthropic.d.ts +1 -1
  6. package/dist/types/providers/cursor.d.ts +33 -21
  7. package/dist/types/providers/devin-acp.d.ts +157 -0
  8. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  9. package/dist/types/providers/mock.d.ts +2 -0
  10. package/dist/types/providers/openai-responses-shared.d.ts +21 -1
  11. package/dist/types/providers/register-builtins.d.ts +1 -0
  12. package/dist/types/types.d.ts +52 -11
  13. package/dist/types/utils/block-symbols.d.ts +15 -5
  14. package/dist/types/utils/fallback-transport.d.ts +4 -1
  15. package/dist/types/utils.d.ts +13 -0
  16. package/package.json +4 -3
  17. package/src/api-registry.ts +1 -0
  18. package/src/auth-broker/redact.ts +10 -2
  19. package/src/auth-gateway/server.ts +56 -3
  20. package/src/auth-storage.ts +330 -116
  21. package/src/model-manager.ts +21 -2
  22. package/src/models.d.ts +14 -0
  23. package/src/models.json +117 -0
  24. package/src/models.ts +18 -0
  25. package/src/provider-models/descriptors.ts +7 -0
  26. package/src/provider-models/openai-compat.ts +14 -0
  27. package/src/provider-models/special.ts +39 -0
  28. package/src/providers/anthropic.d.ts +1 -1
  29. package/src/providers/anthropic.ts +1 -1
  30. package/src/providers/azure-openai-responses.ts +10 -1
  31. package/src/providers/cursor.d.ts +33 -21
  32. package/src/providers/cursor.ts +2024 -508
  33. package/src/providers/devin-acp.d.ts +157 -0
  34. package/src/providers/devin-acp.ts +1103 -0
  35. package/src/providers/google-gemini-headers.d.ts +1 -1
  36. package/src/providers/google-gemini-headers.ts +1 -1
  37. package/src/providers/mock.ts +16 -1
  38. package/src/providers/openai-chat-server.ts +3 -3
  39. package/src/providers/openai-codex-responses.ts +27 -17
  40. package/src/providers/openai-responses-server.ts +5 -5
  41. package/src/providers/openai-responses-shared.d.ts +21 -1
  42. package/src/providers/openai-responses-shared.ts +60 -6
  43. package/src/providers/openai-responses.ts +10 -1
  44. package/src/providers/register-builtins.d.ts +1 -0
  45. package/src/providers/register-builtins.ts +21 -1
  46. package/src/stream.ts +14 -0
  47. package/src/types.d.ts +52 -11
  48. package/src/types.ts +70 -8
  49. package/src/utils/block-symbols.d.ts +15 -5
  50. package/src/utils/block-symbols.ts +16 -6
  51. package/src/utils/discovery/cursor.ts +3 -2
  52. package/src/utils/fallback-transport.d.ts +4 -1
  53. package/src/utils/fallback-transport.ts +12 -5
  54. package/src/utils.d.ts +13 -0
  55. package/src/utils.ts +17 -0
  56. package/dist/types/utils/codex-entitlement.d.ts +0 -22
  57. package/src/utils/codex-entitlement.d.ts +0 -22
  58. package/src/utils/codex-entitlement.ts +0 -57
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import http2 from "node:http2";
4
+ import type * as tls from "node:tls";
4
5
  import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
5
6
  import { ValueSchema } from "@bufbuild/protobuf/wkt";
6
7
  import { $env, extractHttpStatusFromError, sanitizeText } from "@gajae-code/utils";
@@ -27,9 +28,10 @@ import type {
27
28
  Usage,
28
29
  } from "../types";
29
30
  import { normalizeSystemPrompts } from "../utils";
30
- import { kCursorExecResolved } from "../utils/block-symbols";
31
+ import { kProviderResolvedToolCall } from "../utils/block-symbols";
31
32
  import { AssistantMessageEventStream } from "../utils/event-stream";
32
- import { getStreamIdleTimeoutMs } from "../utils/idle-iterator";
33
+ import { transportFailureFacts } from "../utils/fallback-transport";
34
+ import { FirstEventTimeoutError, getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs } from "../utils/idle-iterator";
33
35
  import { captureUnicodeEscapeEvidence, parseStreamingJson } from "../utils/json-parse";
34
36
  import { connectProxiedSocket, getProxyForUrl } from "../utils/proxy";
35
37
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
@@ -175,9 +177,28 @@ import {
175
177
  export const CURSOR_API_URL = "https://api2.cursor.sh";
176
178
  export { CURSOR_CLIENT_VERSION };
177
179
 
178
- const conversationStateCache = new Map<string, ConversationStateStructure>();
179
- const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
180
- const conversationUsageContextCache = new Map<string, CursorUsageContext>();
180
+ interface CursorConversationContext {
181
+ endpointKey: string;
182
+ credentialKey: string;
183
+ modelKey: string;
184
+ systemPromptKey: string;
185
+ customSystemPromptKey: string;
186
+ toolsKey: string;
187
+ messageKeys: string[];
188
+ }
189
+
190
+ interface CursorConversationCacheEntry {
191
+ state: ConversationStateStructure;
192
+ blobs: Map<string, Uint8Array>;
193
+ context: CursorConversationContext;
194
+ }
195
+
196
+ const conversationCache = new Map<string, CursorConversationCacheEntry>();
197
+ // A capped non-abortable mutation may outlive the provider turn. Keep a
198
+ // conversation-scoped lock until the actual handler settles so a retry or a
199
+ // later turn cannot start another request while the detached mutation is still
200
+ // changing local state.
201
+ const conversationMutationLocks = new Map<string, Promise<void>>();
181
202
 
182
203
  // F15: bound the module-global conversation caches so long-lived / many-session use cannot
183
204
  // grow them without limit. LRU by conversation count + TTL on idle conversations.
@@ -185,14 +206,95 @@ const CURSOR_MAX_CONVERSATIONS = 64;
185
206
  const CURSOR_CONVERSATION_TTL_MS = 60 * 60 * 1000;
186
207
  const conversationLastAccess = new Map<string, number>();
187
208
 
209
+ const conversationMutationLockReservations = new Set<string>();
210
+
211
+ function reserveCursorMutationLock(conversationId: string): boolean {
212
+ if (conversationMutationLocks.has(conversationId) || conversationMutationLockReservations.has(conversationId))
213
+ return false;
214
+ if (conversationMutationLocks.size + conversationMutationLockReservations.size >= CURSOR_MAX_CONVERSATIONS) {
215
+ return false;
216
+ }
217
+ conversationMutationLockReservations.add(conversationId);
218
+ return true;
219
+ }
220
+
221
+ function releaseCursorMutationLockReservation(conversationId: string): void {
222
+ conversationMutationLockReservations.delete(conversationId);
223
+ }
224
+
225
+ function registerCursorMutationLock(conversationId: string, lock: Promise<void>): void {
226
+ conversationMutationLocks.set(conversationId, lock);
227
+ void lock.then(() => {
228
+ if (conversationMutationLocks.get(conversationId) === lock) conversationMutationLocks.delete(conversationId);
229
+ });
230
+ }
231
+
188
232
  /** Drop all cached state + blob bytes for a conversation (F15 bound + session-teardown hook). */
189
233
  export function disposeCursorConversation(conversationId: string): void {
190
- conversationStateCache.delete(conversationId);
191
- conversationBlobStores.delete(conversationId);
192
- conversationUsageContextCache.delete(conversationId);
234
+ conversationCache.delete(conversationId);
193
235
  conversationLastAccess.delete(conversationId);
194
236
  }
195
237
 
238
+ async function waitForCursorSetup<T>(
239
+ setup: Promise<T>,
240
+ signal: AbortSignal | undefined,
241
+ timeoutMs: number | undefined,
242
+ timeoutError: () => Error,
243
+ ): Promise<T> {
244
+ setup.catch(() => {});
245
+ const deadline = Promise.withResolvers<never>();
246
+ deadline.promise.catch(() => {});
247
+ const deadlineTimer =
248
+ timeoutMs !== undefined && timeoutMs > 0
249
+ ? setTimeout(() => deadline.reject(timeoutError()), timeoutMs)
250
+ : undefined;
251
+ const aborted = Promise.withResolvers<never>();
252
+ aborted.promise.catch(() => {});
253
+ const onAbort = () => aborted.reject(cursorAbortError(signal!));
254
+ if (signal) {
255
+ signal.addEventListener("abort", onAbort, { once: true });
256
+ if (signal.aborted) onAbort();
257
+ }
258
+ try {
259
+ const racers: Promise<T | never>[] = [setup];
260
+ if (deadlineTimer) racers.push(deadline.promise);
261
+ if (signal) racers.push(aborted.promise);
262
+ return await Promise.race(racers);
263
+ } finally {
264
+ if (signal) signal.removeEventListener("abort", onAbort);
265
+ if (deadlineTimer) clearTimeout(deadlineTimer);
266
+ }
267
+ }
268
+
269
+ async function waitForCursorMutationLock(
270
+ conversationId: string,
271
+ signal: AbortSignal | undefined,
272
+ timeoutMs: number | undefined,
273
+ timeoutError: () => Error,
274
+ ): Promise<void> {
275
+ const lock = conversationMutationLocks.get(conversationId);
276
+ if (!lock) return;
277
+ if (signal?.aborted) throw cursorAbortError(signal);
278
+ const deadline = Promise.withResolvers<never>();
279
+ deadline.promise.catch(() => {});
280
+ const deadlineTimer =
281
+ timeoutMs !== undefined && timeoutMs > 0
282
+ ? setTimeout(() => deadline.reject(timeoutError()), timeoutMs)
283
+ : undefined;
284
+ const aborted = Promise.withResolvers<never>();
285
+ aborted.promise.catch(() => {});
286
+ const onAbort = () => aborted.reject(cursorAbortError(signal!));
287
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
288
+ try {
289
+ const racers: Promise<never>[] = [deadline.promise];
290
+ if (signal) racers.push(aborted.promise);
291
+ await Promise.race([lock, ...racers]);
292
+ } finally {
293
+ if (signal) signal.removeEventListener("abort", onAbort);
294
+ if (deadlineTimer) clearTimeout(deadlineTimer);
295
+ }
296
+ }
297
+
196
298
  /** Refresh recency for a conversation and evict TTL-stale / LRU-overflow entries (F15). */
197
299
  function touchCursorConversation(conversationId: string): void {
198
300
  const now = Date.now();
@@ -200,13 +302,13 @@ function touchCursorConversation(conversationId: string): void {
200
302
  if (id !== conversationId && now - ts > CURSOR_CONVERSATION_TTL_MS) disposeCursorConversation(id);
201
303
  }
202
304
  conversationLastAccess.set(conversationId, now);
203
- const state = conversationStateCache.get(conversationId);
204
- if (state !== undefined) {
205
- conversationStateCache.delete(conversationId);
206
- conversationStateCache.set(conversationId, state);
305
+ const entry = conversationCache.get(conversationId);
306
+ if (entry !== undefined) {
307
+ conversationCache.delete(conversationId);
308
+ conversationCache.set(conversationId, entry);
207
309
  }
208
- while (conversationStateCache.size > CURSOR_MAX_CONVERSATIONS) {
209
- const oldest = conversationStateCache.keys().next().value;
310
+ while (conversationCache.size > CURSOR_MAX_CONVERSATIONS) {
311
+ const oldest = conversationCache.keys().next().value;
210
312
  if (oldest === undefined || oldest === conversationId) break;
211
313
  disposeCursorConversation(oldest);
212
314
  }
@@ -220,6 +322,316 @@ export interface CursorOptions extends StreamOptions {
220
322
  }
221
323
 
222
324
  const CONNECT_END_STREAM_FLAG = 0b00000010;
325
+ const CURSOR_MAX_PENDING_SERVER_MESSAGES = 256;
326
+ const CURSOR_MAX_QUEUED_SERVER_BYTES = 64 * 1024 * 1024;
327
+ // Connect frames routinely carry tool payloads and checkpoint blobs larger than
328
+ // 4 KiB. Keep a finite protocol bound for hostile peers, but do not reject
329
+ // valid server messages merely because they exceed the old debug-text limit.
330
+ const CURSOR_MAX_GRPC_MESSAGE_LENGTH = 16 * 1024 * 1024;
331
+ // A held exec cannot be allowed to turn the response stream into an unbounded
332
+ // staging area. One maximum-sized frame plus its envelope is enough to retain
333
+ // a complete frame while parser backpressure is active; additional input is a
334
+ // protocol failure rather than silently dropping raw progress.
335
+ const CURSOR_MAX_PENDING_SERVER_BYTES = CURSOR_MAX_GRPC_MESSAGE_LENGTH + 5;
336
+ // The conversation blob store is a content-addressed cache written by BOTH
337
+ // sides: request construction stores one blob per history message plus the
338
+ // per-turn structures, and the server stores its own state through `setBlob`.
339
+ // Bound it by bytes only. A separate entry ceiling was below the working set of
340
+ // an ordinary long session — a few hundred small blobs — so it rejected writes
341
+ // while the store held well under a megabyte.
342
+ const CURSOR_MAX_BLOB_STORE_BYTES = 64 * 1024 * 1024;
343
+ const CURSOR_BLOB_ID_BYTES = 32;
344
+
345
+ /** Exported for deterministic validation of fragmented Connect progress. */
346
+ export function isPlausibleCursorConnectProgressForTest(
347
+ bufferedLength: number,
348
+ flags: number,
349
+ messageLength?: number,
350
+ ): boolean {
351
+ if (bufferedLength <= 0 || (flags & ~CONNECT_END_STREAM_FLAG) !== 0) return false;
352
+ if (bufferedLength < 5) return true;
353
+ return messageLength !== undefined && messageLength <= CURSOR_MAX_GRPC_MESSAGE_LENGTH;
354
+ }
355
+ const CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH = 4096;
356
+ const CURSOR_EXEC_DEADLINE_MULTIPLIER = 4;
357
+ const CURSOR_MIN_EXEC_DEADLINE_MS = 100;
358
+
359
+ interface CursorPendingChunk {
360
+ bytes: Buffer;
361
+ offset: number;
362
+ next: CursorPendingChunk | null;
363
+ }
364
+
365
+ /**
366
+ * Bounded response staging for Connect frames. Incoming HTTP/2 chunks are
367
+ * retained by reference and consumed from the head; a frame split across
368
+ * chunks is copied once for protobuf decoding instead of repeatedly growing a
369
+ * single Buffer with Buffer.concat.
370
+ */
371
+ class CursorPendingBuffer {
372
+ #head: CursorPendingChunk | null = null;
373
+ #tail: CursorPendingChunk | null = null;
374
+ #byteLength = 0;
375
+ #lookup: {
376
+ logicalOffset: number;
377
+ chunk: CursorPendingChunk;
378
+ chunkOffset: number;
379
+ } | null = null;
380
+
381
+ get length(): number {
382
+ return this.#byteLength;
383
+ }
384
+
385
+ append(bytes: Uint8Array): void {
386
+ if (bytes.length === 0) return;
387
+ const chunk: CursorPendingChunk = {
388
+ bytes: Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes),
389
+ offset: 0,
390
+ next: null,
391
+ };
392
+ if (this.#tail) this.#tail.next = chunk;
393
+ else this.#head = chunk;
394
+ this.#tail = chunk;
395
+ this.#byteLength += chunk.bytes.length;
396
+ }
397
+
398
+ clear(): void {
399
+ this.#head = null;
400
+ this.#tail = null;
401
+ this.#byteLength = 0;
402
+ this.#lookup = null;
403
+ }
404
+
405
+ consume(length: number): void {
406
+ if (!Number.isInteger(length) || length < 0 || length > this.#byteLength) {
407
+ throw new RangeError(`Cannot consume ${length} bytes from a ${this.#byteLength}-byte buffer`);
408
+ }
409
+ let remaining = length;
410
+ while (remaining > 0) {
411
+ const chunk = this.#head;
412
+ if (!chunk) throw new RangeError("Pending buffer ended while consuming bytes");
413
+ const available = chunk.bytes.length - chunk.offset;
414
+ const consumed = Math.min(remaining, available);
415
+ chunk.offset += consumed;
416
+ remaining -= consumed;
417
+ if (chunk.offset === chunk.bytes.length) {
418
+ this.#head = chunk.next;
419
+ chunk.next = null;
420
+ if (!this.#head) this.#tail = null;
421
+ }
422
+ }
423
+ this.#byteLength -= length;
424
+ this.#lookup = null;
425
+ }
426
+
427
+ #locate(offset: number): { chunk: CursorPendingChunk; chunkOffset: number } {
428
+ if (!Number.isInteger(offset) || offset < 0 || offset >= this.#byteLength) {
429
+ throw new RangeError(`Pending buffer offset ${offset} is outside ${this.#byteLength} bytes`);
430
+ }
431
+
432
+ let chunk: CursorPendingChunk | null;
433
+ let chunkOffset: number;
434
+ let logicalOffset: number;
435
+ if (this.#lookup && offset >= this.#lookup.logicalOffset) {
436
+ chunk = this.#lookup.chunk;
437
+ chunkOffset = this.#lookup.chunkOffset;
438
+ logicalOffset = this.#lookup.logicalOffset;
439
+ } else {
440
+ chunk = this.#head;
441
+ chunkOffset = chunk?.offset ?? 0;
442
+ logicalOffset = 0;
443
+ }
444
+
445
+ while (chunk) {
446
+ const available = chunk.bytes.length - chunkOffset;
447
+ if (offset < logicalOffset + available) {
448
+ this.#lookup = { logicalOffset: offset, chunk, chunkOffset: chunkOffset + (offset - logicalOffset) };
449
+ return { chunk, chunkOffset: chunkOffset + (offset - logicalOffset) };
450
+ }
451
+ logicalOffset += available;
452
+ chunk = chunk.next;
453
+ chunkOffset = chunk?.offset ?? 0;
454
+ }
455
+
456
+ throw new RangeError(`Pending buffer offset ${offset} is outside ${this.#byteLength} bytes`);
457
+ }
458
+
459
+ byteAt(offset: number): number {
460
+ const location = this.#locate(offset);
461
+ return location.chunk.bytes[location.chunkOffset];
462
+ }
463
+
464
+ readUInt32BE(offset: number): number {
465
+ if (!Number.isInteger(offset) || offset < 0 || offset + 4 > this.#byteLength) {
466
+ throw new RangeError(`Cannot read a 32-bit value at offset ${offset}`);
467
+ }
468
+ return (
469
+ ((this.byteAt(offset) << 24) |
470
+ (this.byteAt(offset + 1) << 16) |
471
+ (this.byteAt(offset + 2) << 8) |
472
+ this.byteAt(offset + 3)) >>>
473
+ 0
474
+ );
475
+ }
476
+
477
+ subarray(offset: number, length: number): Buffer {
478
+ if (!Number.isInteger(length) || length < 0 || offset < 0 || offset + length > this.#byteLength) {
479
+ throw new RangeError(`Cannot slice ${length} bytes at offset ${offset}`);
480
+ }
481
+ if (length === 0) return Buffer.alloc(0);
482
+ const first = this.#locate(offset);
483
+ const contiguous = first.chunk.bytes.length - first.chunkOffset;
484
+ if (length <= contiguous) return first.chunk.bytes.subarray(first.chunkOffset, first.chunkOffset + length);
485
+
486
+ const result = Buffer.allocUnsafe(length);
487
+ let written = 0;
488
+ let chunk: CursorPendingChunk | null = first.chunk;
489
+ let chunkOffset = first.chunkOffset;
490
+ while (chunk && written < length) {
491
+ const available = Math.min(length - written, chunk.bytes.length - chunkOffset);
492
+ chunk.bytes.copy(result, written, chunkOffset, chunkOffset + available);
493
+ written += available;
494
+ chunk = chunk.next;
495
+ chunkOffset = chunk?.offset ?? 0;
496
+ }
497
+ return result;
498
+ }
499
+ }
500
+
501
+ function cursorAbortError(signal: AbortSignal): Error {
502
+ const reason = signal.reason;
503
+ if (reason instanceof Error) {
504
+ // Normalize the default AbortError DOMException Bun supplies when abort()
505
+ // runs without a custom reason: Cursor's established terminal text is
506
+ // "Request was aborted", and the generic-abort matcher keys on it. Keep
507
+ // custom AbortError diagnostics intact; the name alone does not prove the
508
+ // caller omitted a reason.
509
+ if (
510
+ reason.name === "AbortError" &&
511
+ (reason.message === "The operation was aborted." || reason.message === "This operation was aborted")
512
+ ) {
513
+ return new Error("Request was aborted");
514
+ }
515
+ return reason;
516
+ }
517
+ return new Error("Request was aborted");
518
+ }
519
+
520
+ /** Marker failures must escape resolveExecHandler instead of becoming a late wire response. */
521
+ class CursorExecAdmissionClosedError extends Error {
522
+ constructor(message = "Cursor non-abortable exec was marked after wrapper terminalization") {
523
+ super(message);
524
+ this.name = "CursorExecAdmissionClosedError";
525
+ }
526
+ }
527
+
528
+ /** Exported for deterministic coverage of the Cursor exec-budget derivation. */
529
+ export function cursorExecDeadlineMsForTest(idleTimeoutMs: number | undefined): number {
530
+ // A non-positive idle override explicitly DISABLES the transport watchdog;
531
+ // it must not collapse the exec deadline to the minimum clamp. Treat it
532
+ // like the normal 120-second input so disabling transport watching cannot
533
+ // make local tools stricter than the default (for example, bash's 300s).
534
+ if (idleTimeoutMs === undefined || idleTimeoutMs <= 0) {
535
+ return 120_000 * CURSOR_EXEC_DEADLINE_MULTIPLIER;
536
+ }
537
+ return Math.max(CURSOR_MIN_EXEC_DEADLINE_MS, idleTimeoutMs * CURSOR_EXEC_DEADLINE_MULTIPLIER);
538
+ }
539
+
540
+ /** Settlement proof for a started non-abortable Cursor exec. */
541
+ export interface CursorNonAbortableSettlement {
542
+ /** Resolves when the marked mutation settles; never rejects. */
543
+ settled: Promise<void>;
544
+ }
545
+
546
+ function runWithCursorExecDeadline<T>(
547
+ operation: (signal: AbortSignal, markNonAbortable: () => void) => Promise<T>,
548
+ signal: AbortSignal | undefined,
549
+ deadlineMs: number,
550
+ onNonAbortableStarted?: (settlement: CursorNonAbortableSettlement) => void,
551
+ onOperationFinished?: () => void,
552
+ onWrapperFinished?: () => void,
553
+ onControllerReady?: (abort: (reason?: Error) => void) => void,
554
+ transportTerminated?: () => boolean,
555
+ ): Promise<T> {
556
+ const result = Promise.withResolvers<T>();
557
+ const operationCompletion = Promise.withResolvers<void>();
558
+ operationCompletion.promise.catch(() => {});
559
+ const controller = new AbortController();
560
+ const abortController = (reason?: Error): void => {
561
+ if (!controller.signal.aborted) controller.abort(reason);
562
+ };
563
+ let settled = false;
564
+ let nonAbortableStarted = false;
565
+ let abortError: Error | undefined;
566
+ let timer: NodeJS.Timeout | undefined;
567
+
568
+ const cleanup = () => {
569
+ if (timer) clearTimeout(timer);
570
+ if (signal) signal.removeEventListener("abort", onAbort);
571
+ };
572
+ const settle = (settlement: () => void) => {
573
+ if (settled) return;
574
+ settled = true;
575
+ cleanup();
576
+ onWrapperFinished?.();
577
+ settlement();
578
+ };
579
+ const onAbort = () => {
580
+ if (signal) {
581
+ abortError = cursorAbortError(signal);
582
+ abortController(abortError);
583
+ if (!nonAbortableStarted) settle(() => result.reject(abortError!));
584
+ }
585
+ };
586
+ onControllerReady?.(abortController);
587
+
588
+ if (signal?.aborted) {
589
+ abortController(cursorAbortError(signal));
590
+ settle(() => result.reject(cursorAbortError(signal)));
591
+ onOperationFinished?.();
592
+ return result.promise;
593
+ }
594
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
595
+ timer = setTimeout(() => {
596
+ // The deadline cannot forcibly cancel the handler's local work; it aborts
597
+ // the per-exec signal so cooperative tools stop, and the rejection below
598
+ // still bounds how long this turn waits for the handler promise.
599
+ abortError = new Error(`Cursor local exec exceeded its ${deadlineMs}ms deadline`);
600
+ abortController(abortError);
601
+ if (!nonAbortableStarted) settle(() => result.reject(abortError!));
602
+ }, deadlineMs);
603
+ void operation(controller.signal, () => {
604
+ if (nonAbortableStarted) return;
605
+ if (settled || transportTerminated?.()) {
606
+ throw new CursorExecAdmissionClosedError();
607
+ }
608
+ nonAbortableStarted = true;
609
+ onNonAbortableStarted?.({
610
+ settled: operationCompletion.promise,
611
+ });
612
+ }).then(
613
+ value => {
614
+ operationCompletion.resolve();
615
+ onOperationFinished?.();
616
+ settle(() => (abortError ? result.reject(abortError) : result.resolve(value)));
617
+ },
618
+ error => {
619
+ operationCompletion.resolve();
620
+ onOperationFinished?.();
621
+ settle(() => result.reject(abortError ?? error));
622
+ },
623
+ );
624
+ return result.promise;
625
+ }
626
+
627
+ /** Exported for production-bridge coverage of non-abortable terminal ordering. */
628
+ export function runWithCursorExecDeadlineForTest<T>(
629
+ operation: (signal: AbortSignal, markNonAbortable: () => void) => Promise<T>,
630
+ signal: AbortSignal | undefined,
631
+ deadlineMs: number,
632
+ ): Promise<T> {
633
+ return runWithCursorExecDeadline(operation, signal, deadlineMs);
634
+ }
223
635
 
224
636
  interface CursorLogEntry {
225
637
  ts: number;
@@ -256,201 +668,253 @@ function frameConnectMessage(data: Uint8Array, flags = 0): Buffer {
256
668
  return frame;
257
669
  }
258
670
 
259
- function parseConnectEndStream(data: Uint8Array): Error | null {
260
- try {
261
- const payload = JSON.parse(new TextDecoder().decode(data));
262
- const error = payload?.error;
263
- if (error) {
264
- const code = typeof error.code === "string" ? error.code : "unknown";
265
- const message = typeof error.message === "string" ? error.message : "Unknown error";
266
- return new Error(`Connect error ${code}: ${message}`);
267
- }
268
- return null;
269
- } catch {
270
- return new Error("Failed to parse Connect end stream");
271
- }
671
+ function isClosedCursorRequest(request: http2.ClientHttp2Stream): boolean {
672
+ return request.closed || request.destroyed || request.writableEnded || request.writableFinished;
272
673
  }
273
674
 
274
- interface CursorRequestWriter {
275
- enqueue(frame: Uint8Array): void;
276
- isActive(): boolean;
277
- registerShellGate(close: () => void): () => void;
278
- }
675
+ const CURSOR_WRITE_DRAIN_TIMEOUT_MS = 5_000;
676
+ const CURSOR_MAX_PENDING_SHELL_WRITE_BYTES = 1024 * 1024;
677
+ const pendingCursorWrites = new WeakMap<object, Set<Promise<void>>>();
678
+ const cursorWriteErrors = new WeakMap<object, unknown>();
279
679
 
280
- class CursorRequestCoordinator implements CursorRequestWriter {
281
- #state: "open" | "draining" | "failed" | "succeeded" = "open";
282
- #tasks = new Set<Promise<void>>();
283
- #taskChain = Promise.resolve();
284
- #hasAdmittedTask = false;
285
- #frames: Uint8Array[] = [];
286
- #writing = false;
287
- #drainWaiters: Array<() => void> = [];
288
- #failure: Error | null = null;
289
- #shellGates = new Set<() => void>();
290
- #request: http2.ClientHttp2Stream;
291
- #stopHeartbeat: () => void;
292
- #onSuccess: () => void;
293
- #onFailure: (error: Error) => void;
294
- #drainTimer: NodeJS.Timeout | null = null;
295
- #drainTimeoutMs: number | undefined;
296
-
297
- constructor(
298
- request: http2.ClientHttp2Stream,
299
- stopHeartbeat: () => void,
300
- onSuccess: () => void,
301
- onFailure: (error: Error) => void,
302
- drainTimeoutMs: number | undefined,
303
- ) {
304
- this.#request = request;
305
- this.#stopHeartbeat = stopHeartbeat;
306
- this.#onSuccess = onSuccess;
307
- this.#onFailure = onFailure;
308
- this.#drainTimeoutMs = drainTimeoutMs;
680
+ function closeStalledCursorRequest(request: http2.ClientHttp2Stream): void {
681
+ // A request whose peer stopped reading may never invoke a write callback. Close
682
+ // and destroy both sides of the stream so the bounded drain cannot leave a
683
+ // live HTTP/2 transport behind. Test writers may only implement one of these
684
+ // methods, hence the defensive checks.
685
+ try {
686
+ request.close?.();
687
+ } catch {
688
+ // Teardown is best effort; the timeout remains the authoritative result.
309
689
  }
310
-
311
- isActive(): boolean {
312
- return this.#state === "open" || this.#state === "draining";
690
+ try {
691
+ request.destroy?.();
692
+ } catch {
693
+ // Teardown is best effort; the timeout remains the authoritative result.
313
694
  }
695
+ }
314
696
 
315
- canAdmitTask(): boolean {
316
- return this.#state === "open";
697
+ /** Await request-side END_STREAM under the same bounded teardown contract used by Cursor streams. */
698
+ export async function endCursorRequestForTest(
699
+ request: Pick<http2.ClientHttp2Stream, "end">,
700
+ timeoutMs = 100,
701
+ ): Promise<boolean> {
702
+ const completion = Promise.withResolvers<boolean>();
703
+ let settled = false;
704
+ const settle = (value: boolean): void => {
705
+ if (settled) return;
706
+ settled = true;
707
+ completion.resolve(value);
708
+ };
709
+ const timer = setTimeout(() => settle(false), timeoutMs);
710
+ try {
711
+ request.end(() => settle(true));
712
+ } catch {
713
+ settle(false);
317
714
  }
715
+ const completed = await completion.promise;
716
+ clearTimeout(timer);
717
+ return completed;
718
+ }
318
719
 
319
- hasTurnEnded(): boolean {
320
- return this.#state === "draining" || this.#state === "succeeded";
720
+ /** Wait until every frame accepted by a request has reached the HTTP/2 writer. */
721
+ async function waitForCursorWrites(
722
+ request: http2.ClientHttp2Stream | null,
723
+ timeoutMs = CURSOR_WRITE_DRAIN_TIMEOUT_MS,
724
+ onTimeout?: (error: Error) => void,
725
+ ): Promise<void> {
726
+ if (!request) return;
727
+ const pending = pendingCursorWrites.get(request);
728
+ if (!pending) return;
729
+ const boundedTimeoutMs = Math.max(1, timeoutMs);
730
+ const deadline = Date.now() + boundedTimeoutMs;
731
+ let timeout: NodeJS.Timeout | undefined;
732
+ try {
733
+ while (pending.size > 0) {
734
+ const writesDone = Promise.all([...pending]);
735
+ // The timeout race may win while one or more writes later reject. Keep a
736
+ // rejection handler attached so late callback errors never become unhandled.
737
+ writesDone.catch(() => {});
738
+ const remainingMs = deadline - Date.now();
739
+ if (remainingMs <= 0) {
740
+ const error = new Error(`Cursor request write drain timed out after ${boundedTimeoutMs}ms`);
741
+ try {
742
+ onTimeout?.(error);
743
+ } catch {
744
+ // The transport teardown callback is best effort.
745
+ }
746
+ if (!onTimeout) closeStalledCursorRequest(request);
747
+ throw error;
748
+ }
749
+ const timeoutDeferred = Promise.withResolvers<never>();
750
+ timeoutDeferred.promise.catch(() => {});
751
+ timeout = setTimeout(() => {
752
+ const error = new Error(`Cursor request write drain timed out after ${boundedTimeoutMs}ms`);
753
+ // Reject first so teardown callbacks that synchronously complete or
754
+ // fail a write cannot replace the deterministic timeout outcome.
755
+ timeoutDeferred.reject(error);
756
+ try {
757
+ onTimeout?.(error);
758
+ } catch {
759
+ // The transport teardown callback is best effort.
760
+ }
761
+ if (!onTimeout) closeStalledCursorRequest(request);
762
+ }, remainingMs);
763
+ const timeoutPromise = timeoutDeferred.promise;
764
+ await Promise.race([writesDone, timeoutPromise]);
765
+ if (timeout) {
766
+ clearTimeout(timeout);
767
+ timeout = undefined;
768
+ }
769
+ const writeError = cursorWriteErrors.get(request);
770
+ if (writeError !== undefined) throw writeError;
771
+ }
772
+ const writeError = cursorWriteErrors.get(request);
773
+ if (writeError !== undefined) throw writeError;
774
+ } finally {
775
+ if (timeout) clearTimeout(timeout);
776
+ pendingCursorWrites.delete(request);
777
+ cursorWriteErrors.delete(request);
321
778
  }
779
+ }
322
780
 
323
- failureError(): Error | null {
324
- return this.#failure;
325
- }
781
+ /** Exported for deterministic coverage of successful writer teardown ordering. */
782
+ export function waitForCursorWritesForTest(request: http2.ClientHttp2Stream | null, timeoutMs?: number): Promise<void> {
783
+ return waitForCursorWrites(request, timeoutMs);
784
+ }
326
785
 
327
- enqueue(frame: Uint8Array): void {
328
- if (!this.isActive()) return;
329
- this.#frames.push(Buffer.from(frame));
330
- this.#writeNext();
786
+ async function waitForCursorWriteDrain(
787
+ request: http2.ClientHttp2Stream,
788
+ timeoutMs = CURSOR_WRITE_DRAIN_TIMEOUT_MS,
789
+ ): Promise<void> {
790
+ if (isClosedCursorRequest(request)) throw new Error("Cursor request closed while waiting for write backpressure");
791
+ const settled = Promise.withResolvers<void>();
792
+ const onDrain = () => settled.resolve();
793
+ const onClose = () => settled.reject(new Error("Cursor request closed while waiting for write backpressure"));
794
+ const onError = (error: unknown) => settled.reject(error);
795
+ request.once("drain", onDrain);
796
+ request.once("close", onClose);
797
+ request.once("error", onError);
798
+ const timeout = setTimeout(
799
+ () => {
800
+ const error = new Error(`Cursor request write backpressure timed out after ${timeoutMs}ms`);
801
+ settled.reject(error);
802
+ closeStalledCursorRequest(request);
803
+ },
804
+ Math.max(1, timeoutMs),
805
+ );
806
+ try {
807
+ await settled.promise;
808
+ } finally {
809
+ clearTimeout(timeout);
810
+ request.removeListener("drain", onDrain);
811
+ request.removeListener("close", onClose);
812
+ request.removeListener("error", onError);
331
813
  }
814
+ }
332
815
 
333
- registerShellGate(close: () => void): () => void {
334
- if (!this.isActive()) {
335
- close();
336
- return () => {};
337
- }
338
- this.#shellGates.add(close);
339
- return () => this.#shellGates.delete(close);
340
- }
816
+ export function waitForCursorWriteDrainForTest(request: http2.ClientHttp2Stream, timeoutMs?: number): Promise<void> {
817
+ return waitForCursorWriteDrain(request, timeoutMs);
818
+ }
341
819
 
342
- admit(taskFactory: () => Promise<void>): void {
343
- if (!this.canAdmitTask()) return;
344
- this.#admitOrdered(taskFactory, false);
820
+ /**
821
+ * Late exec/stream handlers can finish after the bounded settlement fence has
822
+ * closed the HTTP/2 request. Treat those writes as dropped transport output;
823
+ * never let a synchronous write-after-end error escape into the process.
824
+ */
825
+ function writeCursorFrame(request: http2.ClientHttp2Stream, frame: Uint8Array): boolean {
826
+ if (isClosedCursorRequest(request)) return false;
827
+ let completed = false;
828
+ const completion = Promise.withResolvers<void>();
829
+ const pending = pendingCursorWrites.get(request) ?? new Set<Promise<void>>();
830
+ pendingCursorWrites.set(request, pending);
831
+ // The final request drain observes this rejection, but an asynchronous writer
832
+ // callback can run before that drain starts. Mark it handled immediately so a
833
+ // late transport error cannot surface as an unhandled rejection in the gap.
834
+ completion.promise.catch(() => {});
835
+ pending.add(completion.promise);
836
+ const finish = (error?: unknown) => {
837
+ if (completed) return;
838
+ completed = true;
839
+ pending.delete(completion.promise);
840
+ if (error != null && !cursorWriteErrors.has(request)) cursorWriteErrors.set(request, error);
841
+ if (typeof request.removeListener === "function") {
842
+ request.removeListener("close", onClose);
843
+ request.removeListener("error", finish);
844
+ }
845
+ if (error == null) completion.resolve();
846
+ else completion.reject(error);
847
+ };
848
+ const onClose = () => finish(new Error("Cursor request closed before write completed"));
849
+ try {
850
+ // The real HTTP/2 stream always exposes EventEmitter methods. Keep the
851
+ // test seam tolerant of a minimal writer stub as well.
852
+ if (typeof request.once === "function") {
853
+ request.once("close", onClose);
854
+ request.once("error", finish);
855
+ }
856
+ return request.write(frame, finish) !== false;
857
+ } catch (error) {
858
+ if (isClosedCursorRequest(request)) {
859
+ finish();
860
+ return false;
861
+ }
862
+ const code = (error as NodeJS.ErrnoException).code;
863
+ if (
864
+ code === "ERR_STREAM_WRITE_AFTER_END" ||
865
+ code === "ERR_HTTP2_INVALID_STREAM" ||
866
+ code === "ERR_HTTP2_STREAM_CLOSED"
867
+ ) {
868
+ finish();
869
+ return false;
870
+ }
871
+ finish(error);
872
+ throw error;
345
873
  }
874
+ }
346
875
 
347
- admitCheckpoint(taskFactory: () => Promise<void>): Promise<void> {
348
- if (this.#state === "failed") return Promise.reject(this.#failure ?? new Error("Cursor request failed"));
349
- return this.#admitOrdered(taskFactory, true);
350
- }
876
+ /** Exported for deterministic coverage of the post-fence write race. */
877
+ export function writeCursorFrameForTest(request: http2.ClientHttp2Stream, frame: Uint8Array): boolean {
878
+ return writeCursorFrame(request, frame);
879
+ }
351
880
 
352
- #admitOrdered(taskFactory: () => Promise<void>, allowAfterSuccess: boolean): Promise<void> {
353
- const orderedTask = this.#hasAdmittedTask
354
- ? this.#taskChain.then(() => {
355
- if (this.#state === "failed" || (!allowAfterSuccess && this.#state === "succeeded")) return;
356
- return taskFactory();
357
- })
358
- : taskFactory();
359
- this.#hasAdmittedTask = true;
360
- this.#taskChain = orderedTask.then(
361
- () => {},
362
- error => {
363
- this.fail(error instanceof Error ? error : new Error(String(error)));
364
- },
365
- );
366
- this.#tasks.add(orderedTask);
367
- void orderedTask.then(
368
- () => this.#tasks.delete(orderedTask),
369
- () => this.#tasks.delete(orderedTask),
370
- );
371
- return orderedTask;
372
- }
373
-
374
- turnEnded(): void {
375
- if (this.#state !== "open") return;
376
- this.#state = "draining";
377
- this.#stopHeartbeat();
378
- if (this.#drainTimeoutMs !== undefined && this.#drainTimeoutMs > 0) {
379
- this.#drainTimer = setTimeout(() => {
380
- this.fail(new Error(`Cursor admitted work drain timed out after ${this.#drainTimeoutMs}ms`));
381
- }, this.#drainTimeoutMs);
382
- }
383
- void Promise.all([...this.#tasks]).then(
384
- () => {
385
- if (this.#state !== "draining") return;
386
- this.#drain(() => {
387
- if (this.#state !== "draining") return;
388
- if (this.#drainTimer) {
389
- clearTimeout(this.#drainTimer);
390
- this.#drainTimer = null;
391
- }
392
- this.#state = "succeeded";
393
- this.#onSuccess();
394
- });
395
- },
396
- error => this.fail(error instanceof Error ? error : new Error(String(error))),
397
- );
398
- }
881
+ interface CursorRequestWriter extends http2.ClientHttp2Stream {
882
+ isActive(): boolean;
883
+ registerShellGate(close: () => void): () => void;
884
+ }
399
885
 
400
- fail(error: Error): void {
401
- if (this.#state === "failed" || this.#state === "succeeded") return;
402
- this.#state = "failed";
403
- this.#failure = error;
404
- if (this.#drainTimer) {
405
- clearTimeout(this.#drainTimer);
406
- this.#drainTimer = null;
407
- }
408
- this.#stopHeartbeat();
409
- for (const close of this.#shellGates) close();
410
- this.#shellGates.clear();
411
- this.#frames = [];
412
- this.#writing = false;
413
- this.#releaseDrains();
414
- this.#request.close();
415
- this.#onFailure(error);
416
- }
417
-
418
- #writeNext(): void {
419
- if (this.#writing || !this.isActive()) return;
420
- const frame = this.#frames.shift();
421
- if (!frame) {
422
- this.#releaseDrains();
423
- return;
886
+ function parseConnectEndStream(data: Uint8Array): Error | null {
887
+ try {
888
+ const payload = JSON.parse(new TextDecoder().decode(data));
889
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
890
+ return new Error("Invalid Connect end stream envelope");
424
891
  }
425
- this.#writing = true;
426
- try {
427
- this.#request.write(frame, error => {
428
- this.#writing = false;
429
- if (error) {
430
- this.fail(error);
431
- return;
432
- }
433
- this.#writeNext();
434
- });
435
- } catch (error) {
436
- this.#writing = false;
437
- this.fail(error instanceof Error ? error : new Error(String(error)));
892
+ if ("error" in payload && (!payload.error || typeof payload.error !== "object" || Array.isArray(payload.error))) {
893
+ return new Error("Invalid Connect end stream error envelope");
438
894
  }
439
- }
440
-
441
- #drain(resolve: () => void): void {
442
- if (!this.#writing && this.#frames.length === 0) {
443
- resolve();
444
- return;
895
+ const error = payload.error as { code?: unknown; message?: unknown } | undefined;
896
+ if (error) {
897
+ const code =
898
+ typeof error.code === "string" ? error.code.slice(0, CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH) : "unknown";
899
+ const message =
900
+ typeof error.message === "string"
901
+ ? error.message.slice(0, CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH)
902
+ : "Unknown error";
903
+ return new Error(`Connect error ${code}: ${message}`);
445
904
  }
446
- this.#drainWaiters.push(resolve);
905
+ return null;
906
+ } catch {
907
+ return new Error("Failed to parse Connect end stream");
447
908
  }
909
+ }
448
910
 
449
- #releaseDrains(): void {
450
- if (this.#writing || this.#frames.length > 0) return;
451
- const waiters = this.#drainWaiters;
452
- this.#drainWaiters = [];
453
- for (const resolve of waiters) resolve();
911
+ function decodeGrpcMessage(value: unknown): string {
912
+ const raw = typeof value === "string" ? value : value == null ? "" : String(value);
913
+ const boundedRaw = raw.slice(0, CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH);
914
+ try {
915
+ return decodeURIComponent(boundedRaw).slice(0, CURSOR_MAX_GRPC_ERROR_MESSAGE_LENGTH);
916
+ } catch {
917
+ return boundedRaw;
454
918
  }
455
919
  }
456
920
 
@@ -628,12 +1092,35 @@ export function mapH2TransportError(error: unknown, baseUrl: string): unknown {
628
1092
  );
629
1093
  }
630
1094
 
1095
+ /** Whether a decoded server envelope carries a known semantic message. */
1096
+ function isMeaningfulCursorServerMessage(msg: AgentServerMessage): boolean {
1097
+ switch (msg.message.case) {
1098
+ case "interactionUpdate":
1099
+ return msg.message.value.message.case !== undefined;
1100
+ case "execServerMessage":
1101
+ return msg.message.value.message.case !== undefined;
1102
+ case "kvServerMessage":
1103
+ return msg.message.value.message.case !== undefined;
1104
+ case "conversationCheckpointUpdate":
1105
+ return true;
1106
+ default:
1107
+ return false;
1108
+ }
1109
+ }
1110
+
631
1111
  export const streamCursor: StreamFunction<"cursor-agent"> = (
632
1112
  model: Model<"cursor-agent">,
633
1113
  context: Context,
634
1114
  options?: CursorOptions,
635
1115
  ): AssistantMessageEventStream => {
636
1116
  const stream = new AssistantMessageEventStream();
1117
+ // Cursor owns this watchdog, so the budget begins at streamCursor()
1118
+ // invocation—before system-prompt normalization/rule protobuf construction
1119
+ // as well as history/blob/request serialization.
1120
+ const firstEventStartedAt = Date.now();
1121
+ const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
1122
+ const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
1123
+ const endpointClass = (model.baseUrl || CURSOR_API_URL) === CURSOR_API_URL ? "canonical" : "custom";
637
1124
  const requestContextRules = buildCursorRequestContextRules(context.systemPrompt);
638
1125
 
639
1126
  (async () => {
@@ -660,58 +1147,405 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
660
1147
 
661
1148
  let h2Client: http2.ClientHttp2Session | null = null;
662
1149
  let h2Request: http2.ClientHttp2Stream | null = null;
663
- let proxiedSocket: Awaited<ReturnType<typeof connectProxiedSocket>> | null = null;
1150
+ const shellGates = new Set<() => void>();
1151
+ let proxiedSocket: tls.TLSSocket | null = null;
664
1152
  let heartbeatTimer: NodeJS.Timeout | null = null;
665
- let onAbort: (() => void) | undefined;
666
- let coordinator: CursorRequestCoordinator = undefined!;
1153
+ let h2ClientErrorHandler: ((error: Error) => void) | undefined;
1154
+ let h2ClientCloseHandler: (() => void) | undefined;
1155
+ let h2RequestErrorHandler: ((error: Error) => void) | undefined;
1156
+ let h2RequestCloseHandler: (() => void) | undefined;
1157
+ let h2RequestAbortedHandler: (() => void) | undefined;
1158
+ let gracefulCloseCheckTimer: NodeJS.Timeout | undefined;
1159
+ let completedSuccessfully = false;
667
1160
  const baseUrl = model.baseUrl || CURSOR_API_URL;
668
- let activeConversationId: string | undefined;
669
- let previousConversationState: ConversationStateStructure | undefined;
670
- let previousUsageContext: CursorUsageContext | undefined;
1161
+ const h2Completion = Promise.withResolvers<void>();
1162
+ h2Completion.promise.catch(() => {});
1163
+ let h2Settled = false;
1164
+ let h2Failure: unknown;
1165
+ let sawTurnEnded = false;
1166
+ let terminalAdmissionMode: "open" | "raw-eof" | "closed" = "open";
1167
+ let responseEnded = false;
1168
+ let queueDrained = false;
1169
+ let postTurnEndedCheckpointTimer: NodeJS.Timeout | undefined;
1170
+ let endStreamError: Error | null = null;
1171
+ const pendingBuffer = new CursorPendingBuffer();
1172
+ let bufferedObservationOffset = 0;
1173
+ let bufferedObservationTurnEnded = false;
1174
+ const closeTerminalAdmission = (pauseRequest = true): void => {
1175
+ terminalAdmissionMode = "closed";
1176
+ transportWatchdogClosed = true;
1177
+ if (transportWatchdog) {
1178
+ clearTimeout(transportWatchdog);
1179
+ transportWatchdog = null;
1180
+ }
1181
+ if (pauseRequest) h2Request?.pause();
1182
+ };
1183
+ const sealExecAdmissionAtRawEof = (): void => {
1184
+ if (terminalAdmissionMode !== "open") return;
1185
+ terminalAdmissionMode = "raw-eof";
1186
+ };
1187
+ const settleH2 = (error?: unknown): void => {
1188
+ if (h2Settled) return;
1189
+ h2Settled = true;
1190
+ if (error !== undefined) {
1191
+ h2Failure = mapH2TransportError(error, baseUrl);
1192
+ h2Completion.reject(h2Failure);
1193
+ } else {
1194
+ h2Completion.resolve();
1195
+ }
1196
+ };
1197
+ const hasCompleteBufferedFrame = (): boolean =>
1198
+ pendingBuffer.length >= 5 && pendingBuffer.length >= 5 + pendingBuffer.readUInt32BE(1);
1199
+ const hasPlausibleBufferedFrameProgress = (): boolean => {
1200
+ if (pendingBuffer.length === 0) return false;
1201
+ const flags = pendingBuffer.byteAt(0);
1202
+ return isPlausibleCursorConnectProgressForTest(
1203
+ pendingBuffer.length,
1204
+ flags,
1205
+ pendingBuffer.length >= 5 ? pendingBuffer.readUInt32BE(1) : undefined,
1206
+ );
1207
+ };
1208
+ const refreshPostTurnEndedGrace = (): void => {
1209
+ if (!postTurnEndedCheckpointTimer || !hasPlausibleBufferedFrameProgress()) return;
1210
+ clearTimeout(postTurnEndedCheckpointTimer);
1211
+ postTurnEndedCheckpointTimer = undefined;
1212
+ };
1213
+ const settleH2WhenReady = (): void => {
1214
+ if (terminalDrainMode) return;
1215
+ if (!queueDrained) return;
1216
+ if (hasCompleteBufferedFrame()) return;
1217
+ if (endStreamError) {
1218
+ settleBehindFence(() => settleH2(endStreamError));
1219
+ } else if (sawTurnEnded && responseEnded) {
1220
+ // A drained turnEnded is the successful terminal condition; Cursor
1221
+ // may leave the HTTP/2 response open after sending it.
1222
+ settleBehindFence(() => settleH2());
1223
+ } else if (sawTurnEnded && !postTurnEndedCheckpointTimer) {
1224
+ // Cursor may send a final conversation checkpoint immediately after
1225
+ // turnEnded without an END_STREAM frame. Give that non-executable
1226
+ // message a bounded grace window before publishing the terminal.
1227
+ postTurnEndedCheckpointTimer = setTimeout(() => {
1228
+ postTurnEndedCheckpointTimer = undefined;
1229
+ const request = h2Request;
1230
+ if (!request || isClosedCursorRequest(request)) {
1231
+ settleBehindFence(() => settleH2());
1232
+ return;
1233
+ }
1234
+ settleBehindFence(() => {
1235
+ localTransportCloseRequested = true;
1236
+ let finished = false;
1237
+ const finish = (): void => {
1238
+ if (finished) return;
1239
+ finished = true;
1240
+ closeStalledCursorRequest(request);
1241
+ settleH2();
1242
+ };
1243
+ const forceTimer = setTimeout(finish, 100);
1244
+ request.end(() => {
1245
+ clearTimeout(forceTimer);
1246
+ finish();
1247
+ });
1248
+ });
1249
+ }, 25);
1250
+ } else if (responseEnded) {
1251
+ settleBehindFence(() => settleH2(new Error("Cursor HTTP/2 stream ended before turnEnded")));
1252
+ }
1253
+ };
1254
+ let transportWatchdog: NodeJS.Timeout | null = null;
1255
+ let transportWatchdogClosed = false;
1256
+ let callerAbortError: Error | undefined;
1257
+ let pendingNonAbortableExec: CursorNonAbortableSettlement | undefined;
1258
+ let processingPausedForQueue = false;
1259
+ let localTransportCloseRequested = false;
1260
+ let transportTerminalized = false;
1261
+ let terminalDrainMode = false;
1262
+ let terminalDrain: (() => void) | undefined;
1263
+ let terminalDrainStarted = false;
1264
+ let execQueuePrefix: Promise<void> | undefined;
1265
+ let terminalPendingError: unknown;
1266
+ let terminalBoundarySeen = false;
1267
+ // Lookahead can validate turnEnded while an exec handler holds the normal
1268
+ // parser. Close new exec admission immediately, but leave the validated
1269
+ // prefix available for ordered processing once that handler settles.
1270
+ let terminalBoundaryObserved = false;
1271
+ // When lookahead observes turnEnded in a coalesced buffer, retain its byte
1272
+ // offset so processPendingBuffer can drain the validated prefix without
1273
+ // admitting executable frames from the tail after that boundary.
1274
+ let bufferedTerminalBoundaryOffset: number | undefined;
1275
+ let processPendingBuffer: (() => void) | undefined;
1276
+ let activeExecAbort: ((reason?: Error) => void) | undefined;
1277
+ const closeTransportLocally = (): void => {
1278
+ localTransportCloseRequested = true;
1279
+ h2Request?.close();
1280
+ h2Client?.close();
1281
+ proxiedSocket?.destroy();
1282
+ };
1283
+ const forceCloseTransport = (): void => {
1284
+ closeTransportLocally();
1285
+ try {
1286
+ h2Request?.destroy();
1287
+ } catch {
1288
+ // Teardown is best effort; the write-drain timeout remains authoritative.
1289
+ }
1290
+ try {
1291
+ h2Client?.destroy();
1292
+ } catch {
1293
+ // Teardown is best effort; the write-drain timeout remains authoritative.
1294
+ }
1295
+ proxiedSocket?.destroy();
1296
+ };
1297
+ // Terminal publication (caller abort, transport error, or stream end)
1298
+ // must wait for any started non-abortable mutation to settle: a network
1299
+ // reset mid-exec otherwise publishes the terminal and lets a retry start
1300
+ // while the filesystem mutation is still running. Non-abortable means the
1301
+ // mutation promise itself is the terminal boundary; publishing earlier
1302
+ // would permit a post-terminal filesystem commit.
1303
+ const settleBehindFence = (publish: () => void): void => {
1304
+ if (pendingNonAbortableExec) {
1305
+ void pendingNonAbortableExec.settled.then(publish);
1306
+ return;
1307
+ }
1308
+ publish();
1309
+ };
1310
+ const terminalize = (error: unknown, mode: "hard" | "drainable" = "hard"): void => {
1311
+ if (transportTerminalized) {
1312
+ if (callerAbortError) {
1313
+ terminalPendingError = callerAbortError;
1314
+ terminalDrainMode = false;
1315
+ settleBehindFence(() => settleH2(callerAbortError));
1316
+ }
1317
+ return;
1318
+ }
1319
+ transportTerminalized = true;
1320
+ terminalDrainMode = mode === "drainable" && !callerAbortError;
1321
+ if (callerAbortError) terminalPendingError = callerAbortError;
1322
+ else if (terminalPendingError === undefined) terminalPendingError = error;
1323
+ for (const close of shellGates) close();
1324
+ shellGates.clear();
1325
+ activeExecAbort?.(error instanceof Error ? error : new Error(String(error)));
1326
+ activeExecAbort = undefined;
1327
+ closeTerminalAdmission();
1328
+ closeTransportLocally();
1329
+ if (terminalDrainMode) {
1330
+ processPendingBuffer?.();
1331
+ terminalDrain?.();
1332
+ } else {
1333
+ settleBehindFence(() => settleH2(error));
1334
+ }
1335
+ };
1336
+ const closeForCallerAbort = () => {
1337
+ terminalize(callerAbortError!);
1338
+ };
1339
+ const onCallerAbort = () => {
1340
+ const signal = options?.signal;
1341
+ if (!signal) return;
1342
+ if (callerAbortError) return;
1343
+ callerAbortError = cursorAbortError(signal);
1344
+ closeForCallerAbort();
1345
+ };
1346
+ // Abort fence: install the listener before any setup work so a caller that
1347
+ // aborts during payload construction or a proxy handshake can never lose the
1348
+ // race against request creation and credential transmission.
1349
+ if (options?.signal) {
1350
+ // Adding an abort listener to an ALREADY-aborted signal never fires
1351
+ // (and the watchdog-owning provider disabled the wrapper's immediate
1352
+ // aborted check), so onCallerAbort is invoked directly: the cancelled
1353
+ // request must terminate promptly instead of opening an HTTP/2 stream
1354
+ // and lingering until the first-event timeout. The in-try aborted
1355
+ // check converts this into the stream's aborted terminal.
1356
+ if (options.signal.aborted) onCallerAbort();
1357
+ options.signal.addEventListener("abort", onCallerAbort, { once: true });
1358
+ }
1359
+ const usageState: UsageState = {
1360
+ sawTokenDelta: false,
1361
+ conversationUsedTokens: 0,
1362
+ checkpointOutputTokens: 0,
1363
+ hasConversationCheckpoint: false,
1364
+ };
671
1365
 
672
1366
  try {
1367
+ if (options?.signal?.aborted) throw cursorAbortError(options.signal);
673
1368
  const apiKey = options?.apiKey;
674
1369
  if (!apiKey) {
675
1370
  throw new Error("Cursor API key (access token) is required");
676
1371
  }
677
- if (options?.signal?.aborted) {
678
- throw new Error("Request was aborted");
679
- }
680
-
1372
+ if (options?.signal?.aborted) throw cursorAbortError(options.signal);
1373
+ // Cursor owns the first-event watchdog, so its budget starts before
1374
+ // history/blob/protobuf serialization. Synchronous setup cannot be
1375
+ // interrupted by a timer; the remaining-budget check below prevents a
1376
+ // credential-bearing request after serialization already exhausted it.
1377
+ let requestByteLength = 0;
1378
+ const createFirstEventTimeoutError = (): FirstEventTimeoutError =>
1379
+ new FirstEventTimeoutError("Cursor stream timed out while waiting for the first transport event", {
1380
+ requestBytes: requestByteLength,
1381
+ firstEventElapsedMs: Date.now() - firstEventStartedAt,
1382
+ firstEventTimeoutMs,
1383
+ endpointClass,
1384
+ });
1385
+ const getRemainingFirstEventTimeoutMs = (): number | undefined =>
1386
+ firstEventTimeoutMs === undefined || firstEventTimeoutMs <= 0
1387
+ ? firstEventTimeoutMs
1388
+ : firstEventTimeoutMs - (Date.now() - firstEventStartedAt);
1389
+ const assertFirstEventBudget = (): number | undefined => {
1390
+ const remaining = getRemainingFirstEventTimeoutMs();
1391
+ if (
1392
+ remaining !== undefined &&
1393
+ firstEventTimeoutMs !== undefined &&
1394
+ firstEventTimeoutMs > 0 &&
1395
+ remaining <= 0
1396
+ ) {
1397
+ throw createFirstEventTimeoutError();
1398
+ }
1399
+ return remaining;
1400
+ };
681
1401
  const conversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID();
682
- activeConversationId = conversationId;
683
- const cachedBlobStore = conversationBlobStores.get(conversationId);
684
- const blobStore = new Map(cachedBlobStore);
685
- const cachedState = conversationStateCache.get(conversationId);
686
- previousConversationState = cachedState;
687
- const usageContext = buildCursorUsageContext(context, model, options);
688
- previousUsageContext = conversationUsageContextCache.get(conversationId);
689
- conversationUsageContextCache.set(conversationId, usageContext);
690
- const reusableCachedState =
691
- cachedState && canReuseCursorUsageContext(previousUsageContext, usageContext) ? cachedState : undefined;
692
- const { requestBytes, conversationState } = await buildGrpcRequest(model, context, options, {
1402
+ const previousCacheEntry = conversationCache.get(conversationId);
1403
+ const conversationContext = buildCursorConversationContext(context, model, options, baseUrl, apiKey);
1404
+ const reusableCacheEntry =
1405
+ options?.onPayload === undefined &&
1406
+ previousCacheEntry &&
1407
+ canReuseCursorConversationContext(previousCacheEntry.context, conversationContext)
1408
+ ? previousCacheEntry
1409
+ : undefined;
1410
+ // Request construction writes history and attachment blobs. Work against a
1411
+ // private snapshot so aborts, hook failures, and transport failures cannot
1412
+ // publish partial state or leak blobs into a later request reusing the ID.
1413
+ const blobStore = new Map(reusableCacheEntry?.blobs);
1414
+ const cachedState = reusableCacheEntry?.state;
1415
+ usageState.conversationUsedTokens = cachedState?.tokenDetails?.usedTokens ?? 0;
1416
+ const setupPromise = buildGrpcRequest(model, context, options, {
693
1417
  conversationId,
694
1418
  blobStore,
695
- conversationState: reusableCachedState,
1419
+ conversationState: cachedState,
696
1420
  });
697
- conversationStateCache.set(conversationId, conversationState);
1421
+ const { requestBytes, conversationState } = await waitForCursorSetup(
1422
+ setupPromise,
1423
+ options?.signal,
1424
+ assertFirstEventBudget(),
1425
+ createFirstEventTimeoutError,
1426
+ );
1427
+ requestByteLength = requestBytes.length;
1428
+ let remainingFirstEventTimeoutMs = assertFirstEventBudget();
1429
+ // A capped non-abortable mutation remains the conversation's admission
1430
+ // lock until its actual handler settles. Wait before opening another
1431
+ // authenticated transport request.
1432
+ await waitForCursorMutationLock(
1433
+ conversationId,
1434
+ options?.signal,
1435
+ remainingFirstEventTimeoutMs,
1436
+ createFirstEventTimeoutError,
1437
+ );
1438
+ remainingFirstEventTimeoutMs = assertFirstEventBudget();
1439
+ // Recheck immediately before any network work: the caller may have
1440
+ // aborted while the payload was being constructed.
1441
+ if (options?.signal?.aborted) throw cursorAbortError(options.signal);
698
1442
  const requestContextTools = buildMcpToolDefinitions(context.tools);
699
1443
  const targetUrl = new URL(baseUrl);
700
1444
  const proxyUrl = getProxyForUrl(model.provider, targetUrl);
701
- if (options?.signal?.aborted) {
702
- throw new Error("Request was aborted");
703
- }
1445
+ remainingFirstEventTimeoutMs = assertFirstEventBudget();
1446
+
1447
+ // Cursor owns the first-event watchdog, so its budget starts before
1448
+ // history/blob/protobuf serialization. Synchronous setup cannot be
1449
+ // interrupted by a timer; the remaining-budget check below prevents a
1450
+ // credential-bearing request after serialization already exhausted it.
1451
+ const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
1452
+ const clearTransportWatchdog = () => {
1453
+ if (transportWatchdog) {
1454
+ clearTimeout(transportWatchdog);
1455
+ transportWatchdog = null;
1456
+ }
1457
+ };
1458
+ const armTransportWatchdog = (timeoutMs: number | undefined, errorFactory: () => Error) => {
1459
+ if (transportWatchdogClosed) return;
1460
+ clearTransportWatchdog();
1461
+ if (timeoutMs === undefined || timeoutMs <= 0) return;
1462
+ transportWatchdog = setTimeout(() => {
1463
+ if (transportWatchdogClosed) return;
1464
+ const error = errorFactory();
1465
+ terminalize(error);
1466
+ }, timeoutMs);
1467
+ };
1468
+ const refreshTransportWatchdog = () => {
1469
+ if (terminalAdmissionMode !== "open") return;
1470
+ // An in-flight exec handler legitimately produces no inbound frames
1471
+ // while it runs: a heartbeat/checkpoint arriving after it started
1472
+ // must not re-arm the watchdog the exec path cleared, or a slow
1473
+ // local tool call would terminalize the stream mid-exec.
1474
+ if (execInFlight) return;
1475
+ armTransportWatchdog(idleTimeoutMs, () => new Error("stream stalled while waiting for the next event"));
1476
+ };
1477
+ armTransportWatchdog(remainingFirstEventTimeoutMs, createFirstEventTimeoutError);
704
1478
  if (proxyUrl) {
705
- proxiedSocket = await connectProxiedSocket(proxyUrl, baseUrl, {
1479
+ // The watchdog settles the h2 promise but cannot interrupt the
1480
+ // handshake await below: race the tunnel connect against the same
1481
+ // first-event deadline so setup is actually bounded, and destroy the
1482
+ // socket if it materializes after the deadline already fired.
1483
+ const tunnel = connectProxiedSocket(proxyUrl, baseUrl, {
706
1484
  signal: options?.signal,
707
1485
  timeoutMs: 30_000,
708
1486
  });
1487
+ let tunnelDeadline: NodeJS.Timeout | undefined;
1488
+ const deadline = Promise.withResolvers<never>();
1489
+ const boundedTunnel =
1490
+ remainingFirstEventTimeoutMs !== undefined && remainingFirstEventTimeoutMs > 0
1491
+ ? Promise.race([
1492
+ tunnel,
1493
+ deadline.promise.catch(error => {
1494
+ throw error;
1495
+ }),
1496
+ ])
1497
+ : null;
1498
+ if (boundedTunnel) {
1499
+ // The rejection may never be observed when the tunnel wins the
1500
+ // race; keep it handled so it cannot surface as unhandled.
1501
+ deadline.promise.catch(() => {});
1502
+ tunnelDeadline = setTimeout(
1503
+ () => deadline.reject(createFirstEventTimeoutError()),
1504
+ remainingFirstEventTimeoutMs,
1505
+ );
1506
+ }
1507
+ try {
1508
+ proxiedSocket = await (boundedTunnel ?? tunnel);
1509
+ } catch (error) {
1510
+ // If the real tunnel still completes after the deadline won the
1511
+ // race, it must not leak: destroy it as soon as it lands.
1512
+ void tunnel.then(
1513
+ socket => socket.destroy(),
1514
+ () => {},
1515
+ );
1516
+ throw error;
1517
+ } finally {
1518
+ if (tunnelDeadline) clearTimeout(tunnelDeadline);
1519
+ }
1520
+ // The handshake may have outlived the first-event watchdog: never
1521
+ // create the authenticated request once the stream already failed.
1522
+ if (h2Settled) {
1523
+ proxiedSocket.destroy();
1524
+ await h2Completion.promise;
1525
+ }
1526
+ assertFirstEventBudget();
709
1527
  h2Client = http2.connect(baseUrl, { createConnection: () => proxiedSocket! });
710
1528
  } else {
711
1529
  h2Client = http2.connect(baseUrl);
712
1530
  }
1531
+ if (h2Settled) await h2Completion.promise;
1532
+ assertFirstEventBudget();
1533
+ // Recheck after the (possibly async) proxy handshake, immediately before
1534
+ // the bearer-authenticated request is created.
1535
+ if (options?.signal?.aborted) throw cursorAbortError(options.signal);
1536
+ h2ClientErrorHandler = error => {
1537
+ if (terminalBoundarySeen || terminalBoundaryObserved || sawTurnEnded) return;
1538
+ terminalize(error, "drainable");
1539
+ };
1540
+ h2Client.on("error", h2ClientErrorHandler);
1541
+ h2ClientCloseHandler = () => {
1542
+ if (h2Settled || localTransportCloseRequested || responseEnded || sawTurnEnded) return;
1543
+ terminalize(new Error("Cursor HTTP/2 session closed before turnEnded"), "drainable");
1544
+ };
1545
+ h2Client.on("close", h2ClientCloseHandler);
713
1546
 
714
1547
  options?.onStreamCreated?.();
1548
+ if (options?.signal?.aborted) throw cursorAbortError(options.signal);
715
1549
  h2Request = h2Client.request({
716
1550
  ":method": "POST",
717
1551
  ":path": "/agent.v1.AgentService/Run",
@@ -724,78 +1558,77 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
724
1558
  "x-cursor-client-type": "cli",
725
1559
  "x-request-id": crypto.randomUUID(),
726
1560
  });
727
- const stopHeartbeat = () => {
728
- if (heartbeatTimer) {
729
- clearInterval(heartbeatTimer);
730
- heartbeatTimer = null;
1561
+ const writer = h2Request as CursorRequestWriter;
1562
+ writer.isActive = () => !h2Settled && !transportTerminalized && !isClosedCursorRequest(writer);
1563
+ writer.registerShellGate = close => {
1564
+ if (!writer.isActive()) {
1565
+ close();
1566
+ return () => {};
731
1567
  }
1568
+ shellGates.add(close);
1569
+ return () => shellGates.delete(close);
732
1570
  };
733
- let resolveH2: (() => void) | undefined;
734
- let rejectH2: ((error: Error) => void) | undefined;
735
- const inboundEnd = Promise.withResolvers<void>();
736
- let inboundSettled = false;
737
- let inboundTimeout: NodeJS.Timeout | undefined;
738
- const settleInbound = (error?: Error) => {
739
- if (inboundSettled) return;
740
- inboundSettled = true;
741
- if (inboundTimeout) {
742
- clearTimeout(inboundTimeout);
743
- inboundTimeout = undefined;
744
- }
745
- if (error) inboundEnd.reject(error);
746
- else inboundEnd.resolve();
1571
+ h2RequestErrorHandler = error => {
1572
+ if (terminalBoundarySeen || terminalBoundaryObserved || sawTurnEnded) return;
1573
+ terminalize(error, "drainable");
747
1574
  };
748
- void inboundEnd.promise.catch(() => {});
749
- const armInboundTimeout = () => {
750
- const timeoutMs = options?.streamIdleTimeoutMs ?? 0;
751
- if (timeoutMs <= 0 || inboundSettled) return;
752
- inboundTimeout = setTimeout(
753
- () => settleInbound(new Error("Cursor stream did not reach its inbound terminal frame")),
754
- timeoutMs,
755
- );
1575
+ h2Request.on("error", h2RequestErrorHandler);
1576
+ const handleUnexpectedRequestClose = (kind: "closed" | "aborted"): void => {
1577
+ if (h2Settled || localTransportCloseRequested || responseEnded || sawTurnEnded) return;
1578
+ // Node emits `aborted`/`close` with rstCode=0 for a graceful remote
1579
+ // end while a request stream is paused. Preserve the raw-EOF path so
1580
+ // buffered turnEnded frames can still be parsed. A nonzero reset code
1581
+ // is a terminal transport failure: close admission and abort the active
1582
+ // exec before any queued frame can dispatch.
1583
+ if ((h2Request?.rstCode ?? 0) === 0) {
1584
+ // A graceful close can be reported before the final data/end event;
1585
+ // defer one turn so a coalesced turnEnded can establish success. If
1586
+ // no frame arrives, treat a close with no buffered work as terminal
1587
+ // and abort any active exec rather than waiting for the watchdog.
1588
+ if (gracefulCloseCheckTimer) return;
1589
+ gracefulCloseCheckTimer = setTimeout(() => {
1590
+ gracefulCloseCheckTimer = undefined;
1591
+ if (h2Settled || localTransportCloseRequested || responseEnded || sawTurnEnded) return;
1592
+ const error = new Error("Cursor stream ended before turnEnded");
1593
+ if (pendingBuffer.length === 0 && !processingPausedForQueue) {
1594
+ responseEnded = true;
1595
+ terminalize(error, "drainable");
1596
+ return;
1597
+ }
1598
+ responseEnded = true;
1599
+ observeBufferedTerminal(true);
1600
+ if (transportTerminalized) return;
1601
+ if (!sawTurnEnded) {
1602
+ terminalize(
1603
+ pendingBuffer.length > 0 ? new Error("Cursor HTTP/2 stream ended before turnEnded") : error,
1604
+ "drainable",
1605
+ );
1606
+ return;
1607
+ }
1608
+ sealExecAdmissionAtRawEof();
1609
+ processPendingBuffer?.();
1610
+ finishResponseAfterParsing();
1611
+ }, 0);
1612
+ return;
1613
+ }
1614
+ responseEnded = true;
1615
+ terminalize(new Error(`Cursor HTTP/2 request ${kind} before turnEnded`), "drainable");
756
1616
  };
757
- coordinator = new CursorRequestCoordinator(
758
- h2Request,
759
- stopHeartbeat,
760
- () => {
761
- const resolve = resolveH2;
762
- resolveH2 = undefined;
763
- resolve?.();
764
- },
765
- error => {
766
- const reject = rejectH2;
767
- rejectH2 = undefined;
768
- reject?.(error);
769
- },
770
- options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(),
771
- );
772
- h2Client.on("error", error => {
773
- settleInbound(error);
774
- coordinator.fail(error);
775
- });
776
- h2Request.on("error", error => {
777
- settleInbound(error);
778
- coordinator.fail(error);
779
- });
780
-
1617
+ h2RequestCloseHandler = () => handleUnexpectedRequestClose("closed");
1618
+ h2RequestAbortedHandler = () => handleUnexpectedRequestClose("aborted");
1619
+ h2Request.on("close", h2RequestCloseHandler);
1620
+ h2Request.on("aborted", h2RequestAbortedHandler);
1621
+ if (options?.signal?.aborted) throw cursorAbortError(options.signal);
1622
+ // Cursor owns the first-event watchdog, so its budget starts before
1623
+ // history/blob/protobuf serialization. Synchronous setup cannot be
1624
+ // interrupted by a timer; the remaining-budget check below prevents a
1625
+ // credential-bearing request after serialization already exhausted it.
781
1626
  stream.push({ type: "start", partial: output });
782
1627
 
783
- let pendingBuffer = Buffer.alloc(0);
784
- const checkpointTasks: Promise<void>[] = [];
785
1628
  let currentTextBlock: (TextContent & { index: number }) | null = null;
786
1629
  let currentThinkingBlock: (ThinkingContent & { index: number }) | null = null;
787
1630
  let currentToolCall: ToolCallState | null = null;
788
- const cachedConversationUsedTokens =
789
- conversationState.tokenDetails && canReuseCursorUsageContext(previousUsageContext, usageContext)
790
- ? conversationState.tokenDetails.usedTokens
791
- : 0;
792
- const usageState: UsageState = {
793
- sawTokenDelta: false,
794
- conversationUsedTokens: cachedConversationUsedTokens,
795
- checkpointOutputTokens: 0,
796
- hasConversationCheckpoint: false,
797
- };
798
-
1631
+ let pendingConversationCheckpoint: ConversationStateStructure | undefined;
799
1632
  const state: BlockState = {
800
1633
  get currentTextBlock() {
801
1634
  return currentTextBlock;
@@ -824,133 +1657,579 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
824
1657
  };
825
1658
 
826
1659
  const onConversationCheckpoint = (checkpoint: ConversationStateStructure) => {
827
- usageState.pendingCheckpoint = checkpoint;
1660
+ pendingConversationCheckpoint = checkpoint;
828
1661
  };
829
1662
 
1663
+ const messageQueue = createCursorMessageQueueForTest(error => {
1664
+ log("error", "handleServerMessage", { error: String(error) });
1665
+ terminalize(error);
1666
+ });
1667
+ terminalDrain = (): void => {
1668
+ if (terminalDrainStarted) return;
1669
+ terminalDrainStarted = true;
1670
+ // A transport terminal can preempt an abortable exec whose handler ignores
1671
+ // its signal. Do not wait on that queue chain; the bounded settlement fence
1672
+ // below still protects any mutation that explicitly became non-abortable.
1673
+ const queueCompletion =
1674
+ processingPausedForExec || execInFlight ? (execQueuePrefix ?? Promise.resolve()) : messageQueue.drain();
1675
+ void queueCompletion.then(
1676
+ () => {
1677
+ queueDrained = true;
1678
+ settleBehindFence(() => settleH2(terminalPendingError));
1679
+ },
1680
+ error => {
1681
+ queueDrained = true;
1682
+ settleBehindFence(() => settleH2(error));
1683
+ },
1684
+ );
1685
+ };
1686
+ const drainMessageQueue = (): void => {
1687
+ void messageQueue.drain().then(
1688
+ () => {
1689
+ queueDrained = true;
1690
+ settleH2WhenReady();
1691
+ },
1692
+ error => {
1693
+ queueDrained = true;
1694
+ settleBehindFence(() => settleH2(error));
1695
+ },
1696
+ );
1697
+ };
830
1698
  h2Request.on("trailers", trailers => {
831
1699
  const status = trailers["grpc-status"];
832
1700
  const msg = trailers["grpc-message"];
833
1701
  if (status && status !== "0") {
834
- coordinator.fail(new Error(`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`));
1702
+ terminalize(new Error(`gRPC error ${status}: ${decodeGrpcMessage(msg)}`), "drainable");
835
1703
  }
836
1704
  });
837
- h2Request.on("end", () => {
838
- settleInbound();
839
- if (!coordinator.hasTurnEnded()) {
840
- coordinator.fail(new Error("Cursor stream ended before turnEnded"));
1705
+
1706
+ let processingPausedForExec = false;
1707
+ // True while any exec server message handler is running; suppresses
1708
+ // transport-watchdog refreshes for the duration (see refreshTransportWatchdog).
1709
+ let execInFlight = false;
1710
+ /**
1711
+ * Inspect buffered protocol frames while normal parsing is paused behind an
1712
+ * exec. This deliberately shares the Connect/protobuf framing rules with the
1713
+ * main parser: a complete terminal frame can preempt a held handler, while
1714
+ * malformed, oversized, or EOF-truncated bytes fail immediately instead of
1715
+ * remaining in an unbounded side buffer.
1716
+ */
1717
+ const observeBufferedTerminal = (atEof = false): boolean => {
1718
+ // Once a validated terminal boundary is known, every later byte is a
1719
+ // tail. Never inspect its framing: a malformed or oversized tail must
1720
+ // not replace the already-authoritative success.
1721
+ if (terminalBoundarySeen || terminalBoundaryObserved) return true;
1722
+ let offset = bufferedObservationOffset;
1723
+ let observedTurnEnded = sawTurnEnded || bufferedObservationTurnEnded;
1724
+ while (pendingBuffer.length - offset >= 5) {
1725
+ const flags = pendingBuffer.byteAt(offset);
1726
+ const msgLen = pendingBuffer.readUInt32BE(offset + 1);
1727
+ if (msgLen > CURSOR_MAX_GRPC_MESSAGE_LENGTH) {
1728
+ const error = new Error("Cursor HTTP/2 frame exceeds the maximum message length");
1729
+ endStreamError = error;
1730
+ responseEnded = true;
1731
+ terminalize(error);
1732
+ return true;
1733
+ }
1734
+ if (pendingBuffer.length - offset < 5 + msgLen) break;
1735
+ const messageBytes = pendingBuffer.subarray(offset + 5, msgLen);
1736
+ if (flags & CONNECT_END_STREAM_FLAG) {
1737
+ const error = parseConnectEndStream(messageBytes);
1738
+ if (error) {
1739
+ endStreamError = error;
1740
+ responseEnded = true;
1741
+ terminalize(error, "drainable");
1742
+ return true;
1743
+ }
1744
+ if (!observedTurnEnded) {
1745
+ const missingTurnEnded = new Error("Cursor HTTP/2 stream ended before turnEnded");
1746
+ endStreamError = missingTurnEnded;
1747
+ responseEnded = true;
1748
+ terminalize(missingTurnEnded, "drainable");
1749
+ return true;
1750
+ }
1751
+ terminalBoundarySeen = true;
1752
+ closeTerminalAdmission();
1753
+ bufferedObservationOffset = offset + 5 + msgLen;
1754
+ bufferedObservationTurnEnded = observedTurnEnded;
1755
+ drainMessageQueue();
1756
+ return true;
1757
+ }
1758
+ try {
1759
+ const message = fromBinary(AgentServerMessageSchema, messageBytes);
1760
+ if (
1761
+ message.message.case === "interactionUpdate" &&
1762
+ message.message.value.message?.case === "turnEnded"
1763
+ ) {
1764
+ observedTurnEnded = true;
1765
+ sawTurnEnded = true;
1766
+ terminalBoundaryObserved = true;
1767
+ bufferedTerminalBoundaryOffset = offset;
1768
+ closeTerminalAdmission();
1769
+ bufferedObservationOffset = offset + 5 + msgLen;
1770
+ bufferedObservationTurnEnded = true;
1771
+ return true;
1772
+ }
1773
+ } catch (error) {
1774
+ const parseError = error instanceof Error ? error : new Error(String(error));
1775
+ endStreamError = parseError;
1776
+ responseEnded = true;
1777
+ terminalize(parseError);
1778
+ return true;
1779
+ }
1780
+ offset += 5 + msgLen;
841
1781
  }
842
- });
843
- h2Request.on("close", () => {
844
- const error = new Error("Cursor stream closed before inbound completion");
845
- if (!inboundSettled) settleInbound(error);
846
- coordinator.fail(error);
847
- });
848
- onAbort = () => {
849
- settleInbound(new Error("Request was aborted"));
850
- coordinator.fail(new Error("Request was aborted"));
1782
+ bufferedObservationOffset = offset;
1783
+ bufferedObservationTurnEnded = observedTurnEnded;
1784
+ if (atEof && pendingBuffer.length > offset) {
1785
+ const error = new Error("Cursor HTTP/2 stream ended with a truncated Connect frame");
1786
+ endStreamError = error;
1787
+ terminalize(error);
1788
+ return true;
1789
+ }
1790
+ return false;
851
1791
  };
852
- if (options?.signal) {
853
- options.signal.addEventListener("abort", onAbort, { once: true });
854
- if (options.signal.aborted) onAbort();
855
- }
856
-
857
- h2Request.on("data", (chunk: Buffer) => {
858
- pendingBuffer = Buffer.concat([pendingBuffer, chunk]);
859
-
1792
+ const applyBufferedNonExecMessage = (serverMessage: AgentServerMessage): void => {
1793
+ log("serverMessage", serverMessage.message.case, serverMessage.message.value);
1794
+ switch (serverMessage.message.case) {
1795
+ case "interactionUpdate":
1796
+ processInteractionUpdate(serverMessage.message.value, output, stream, state, usageState);
1797
+ return;
1798
+ case "kvServerMessage":
1799
+ handleKvServerMessage(serverMessage.message.value as KvServerMessage, blobStore, writer);
1800
+ return;
1801
+ case "conversationCheckpointUpdate":
1802
+ handleConversationCheckpointUpdate(
1803
+ serverMessage.message.value,
1804
+ output,
1805
+ usageState,
1806
+ onConversationCheckpoint,
1807
+ );
1808
+ return;
1809
+ default:
1810
+ return;
1811
+ }
1812
+ };
1813
+ const finishResponseAfterParsing = (): void => {
1814
+ if (processingPausedForExec || processingPausedForQueue) return;
1815
+ if (!responseEnded) {
1816
+ if (terminalBoundarySeen && !hasCompleteBufferedFrame()) drainMessageQueue();
1817
+ return;
1818
+ }
1819
+ if (terminalBoundarySeen) {
1820
+ // A validated turnEnded makes every remaining byte transport tail,
1821
+ // including an incomplete 1–4 byte frame header. Never turn tail
1822
+ // noise into a truncated-stream failure after the authoritative boundary.
1823
+ pendingBuffer.clear();
1824
+ } else if (pendingBuffer.length > 0) {
1825
+ endStreamError = new Error("Cursor HTTP/2 stream ended with a truncated Connect frame");
1826
+ }
1827
+ drainMessageQueue();
1828
+ };
1829
+ processPendingBuffer = () => {
1830
+ if ((processingPausedForExec || processingPausedForQueue) && !terminalDrainMode) {
1831
+ observeBufferedTerminal(responseEnded);
1832
+ return;
1833
+ }
860
1834
  while (pendingBuffer.length >= 5) {
861
- const flags = pendingBuffer[0];
1835
+ if (terminalBoundarySeen) {
1836
+ const flags = pendingBuffer.byteAt(0);
1837
+ const msgLen = pendingBuffer.readUInt32BE(1);
1838
+ if (pendingBuffer.length < 5 + msgLen) {
1839
+ if (responseEnded) pendingBuffer.clear();
1840
+ break;
1841
+ }
1842
+ const messageBytes = pendingBuffer.subarray(5, msgLen);
1843
+ pendingBuffer.consume(5 + msgLen);
1844
+ if (flags & CONNECT_END_STREAM_FLAG) {
1845
+ responseEnded = true;
1846
+ const endError = parseConnectEndStream(messageBytes);
1847
+ if (endError) {
1848
+ endStreamError = endError;
1849
+ terminalize(endError, "drainable");
1850
+ }
1851
+ pendingBuffer.clear();
1852
+ continue;
1853
+ }
1854
+ try {
1855
+ const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
1856
+ if (serverMessage.message.case === "conversationCheckpointUpdate") {
1857
+ if (messageQueue.pendingBytes() + 5 + msgLen > CURSOR_MAX_QUEUED_SERVER_BYTES) {
1858
+ terminalize(new Error("Cursor server-message queue exceeded its bounded byte capacity"));
1859
+ break;
1860
+ }
1861
+ queueDrained = false;
1862
+ const queuedCheckpoint = messageQueue.enqueue(
1863
+ () => applyBufferedNonExecMessage(serverMessage),
1864
+ 5 + msgLen,
1865
+ );
1866
+ void queuedCheckpoint.catch(() => {});
1867
+ drainMessageQueue();
1868
+ }
1869
+ } catch {
1870
+ // A validated terminal boundary makes all non-checkpoint bytes tail.
1871
+ }
1872
+ continue;
1873
+ }
1874
+ const flags = pendingBuffer.byteAt(0);
862
1875
  const msgLen = pendingBuffer.readUInt32BE(1);
1876
+ if (msgLen > CURSOR_MAX_GRPC_MESSAGE_LENGTH) {
1877
+ terminalize(new Error("Cursor HTTP/2 frame exceeds the maximum message length"));
1878
+ break;
1879
+ }
863
1880
  if (pendingBuffer.length < 5 + msgLen) break;
864
1881
 
865
- const messageBytes = pendingBuffer.subarray(5, 5 + msgLen);
866
- pendingBuffer = pendingBuffer.subarray(5 + msgLen);
1882
+ // Lookahead may have found turnEnded later in this same buffer while
1883
+ // an earlier exec was held. Track the boundary as frames are consumed;
1884
+ // once it is reached, normal parsing handles turnEnded and then drops
1885
+ // the entire tail. This keeps late execs from setting the exec pause.
1886
+ const atBufferedTerminalBoundary = terminalBoundaryObserved && bufferedTerminalBoundaryOffset === 0;
1887
+ const consumedFrameLength = 5 + msgLen;
1888
+ const messageBytes = pendingBuffer.subarray(5, msgLen);
1889
+ pendingBuffer.consume(consumedFrameLength);
1890
+ if (bufferedTerminalBoundaryOffset !== undefined) {
1891
+ bufferedTerminalBoundaryOffset = Math.max(0, bufferedTerminalBoundaryOffset - consumedFrameLength);
1892
+ }
1893
+ bufferedObservationOffset = 0;
1894
+ bufferedObservationTurnEnded = false;
1895
+ if (
1896
+ terminalAdmissionMode === "closed" &&
1897
+ !(flags & CONNECT_END_STREAM_FLAG) &&
1898
+ !terminalDrainMode &&
1899
+ !terminalBoundaryObserved
1900
+ )
1901
+ continue;
867
1902
 
868
1903
  if (flags & CONNECT_END_STREAM_FLAG) {
869
- const endError = parseConnectEndStream(messageBytes);
1904
+ closeTerminalAdmission();
1905
+ responseEnded = true;
1906
+ terminalBoundaryObserved = false;
1907
+ const parsedEndError = parseConnectEndStream(messageBytes);
1908
+ const endError =
1909
+ parsedEndError ??
1910
+ (!sawTurnEnded ? new Error("Cursor HTTP/2 stream ended before turnEnded") : undefined);
870
1911
  if (endError) {
871
- coordinator.fail(endError);
1912
+ endStreamError = endError;
1913
+ terminalize(endError, "drainable");
872
1914
  } else {
873
- coordinator.turnEnded();
1915
+ terminalBoundarySeen = true;
874
1916
  }
875
- continue;
1917
+ pendingBuffer.clear();
1918
+ break;
1919
+ }
1920
+ if (messageQueue.pendingBytes() + consumedFrameLength > CURSOR_MAX_QUEUED_SERVER_BYTES) {
1921
+ terminalize(new Error("Cursor server-message queue exceeded its bounded byte capacity"));
1922
+ break;
876
1923
  }
877
1924
 
878
1925
  try {
879
1926
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
1927
+ // Cursor can make meaningful progress (heartbeats, usage deltas,
1928
+ // checkpoints, and server-side exec) without emitting a normalized
1929
+ // assistant event. Watch the validated Connect/protobuf boundary rather
1930
+ // than the normalized stream so those turns do not false-stall.
1931
+ const isMeaningful = isMeaningfulCursorServerMessage(serverMessage);
1932
+ if (isMeaningful) refreshTransportWatchdog();
880
1933
  const isTurnEnded =
881
1934
  serverMessage.message.case === "interactionUpdate" &&
882
1935
  serverMessage.message.value.message?.case === "turnEnded";
883
1936
  const isConversationCheckpoint = serverMessage.message.case === "conversationCheckpointUpdate";
884
- if (isConversationCheckpoint) {
885
- checkpointTasks.push(
886
- coordinator.admitCheckpoint(() => {
887
- handleConversationCheckpointUpdate(
888
- serverMessage.message.value as ConversationStateStructure,
1937
+ if (isTurnEnded) {
1938
+ // Record the boundary at parse time, before the queued handler runs,
1939
+ // so a following coalesced END_STREAM cannot race ahead of the
1940
+ // already-admitted prefix and report a false missing-turn failure.
1941
+ sawTurnEnded = true;
1942
+ terminalBoundarySeen = true;
1943
+ terminalBoundaryObserved = false;
1944
+ closeTerminalAdmission(false);
1945
+ if (!processingPausedForExec && !processingPausedForQueue) h2Request?.resume();
1946
+ }
1947
+ if (isConversationCheckpoint && terminalBoundarySeen) {
1948
+ applyBufferedNonExecMessage(serverMessage);
1949
+ continue;
1950
+ }
1951
+ if (atBufferedTerminalBoundary && !isTurnEnded) continue;
1952
+ // Serialize handlers: exec messages can be asynchronous, and resolving the
1953
+ // request on turnEnded before prior handlers finish loses their responses.
1954
+ const isExecServerMessage = serverMessage.message.case === "execServerMessage";
1955
+ if (terminalAdmissionMode === "raw-eof" && isExecServerMessage && !sawTurnEnded) continue;
1956
+ if (terminalDrainMode) {
1957
+ if (terminalBoundarySeen || isExecServerMessage) continue;
1958
+ if (isTurnEnded) {
1959
+ sawTurnEnded = true;
1960
+ terminalBoundarySeen = true;
1961
+ closeTerminalAdmission();
1962
+ continue;
1963
+ }
1964
+ applyBufferedNonExecMessage(serverMessage);
1965
+ continue;
1966
+ }
1967
+ const isExecutable = isExecServerMessage && isMeaningful;
1968
+ if (isExecutable) {
1969
+ processingPausedForExec = true;
1970
+ h2Request!.pause();
1971
+ clearTransportWatchdog();
1972
+ execQueuePrefix = messageQueue.drain();
1973
+ }
1974
+ let mutationSlotReserved = false;
1975
+ const queued = messageQueue.enqueue(async () => {
1976
+ const dropExecutable = (): void => {
1977
+ if (!isExecutable) return;
1978
+ processingPausedForExec = false;
1979
+ processPendingBuffer?.();
1980
+ };
1981
+ if (transportTerminalized && !(terminalDrainMode && !isExecServerMessage)) {
1982
+ dropExecutable();
1983
+ return;
1984
+ }
1985
+ if (
1986
+ isExecServerMessage &&
1987
+ terminalAdmissionMode === "closed" &&
1988
+ !(terminalBoundaryObserved && !terminalBoundarySeen)
1989
+ ) {
1990
+ dropExecutable();
1991
+ return;
1992
+ }
1993
+ // An exec frame asks this process to perform work before Cursor can
1994
+ // response. Its deadline is independent from raw transport
1995
+ // progress, and pausing the request supplies bounded backpressure.
1996
+ if (isExecutable) {
1997
+ clearTransportWatchdog();
1998
+ execInFlight = true;
1999
+ }
2000
+ let execSucceeded = false;
2001
+ try {
2002
+ const run = (execSignal?: AbortSignal, markNonAbortable?: () => void) =>
2003
+ handleServerMessage(
2004
+ serverMessage,
889
2005
  output,
2006
+ stream,
2007
+ state,
2008
+ blobStore,
2009
+ writer,
2010
+ options?.execHandlers,
2011
+ options?.onToolResult,
890
2012
  usageState,
2013
+ requestContextTools,
891
2014
  onConversationCheckpoint,
2015
+ requestContextRules,
2016
+ execSignal,
2017
+ markNonAbortable,
892
2018
  );
893
- return Promise.resolve();
894
- }),
895
- );
896
- continue;
2019
+ if (isExecutable) {
2020
+ // The deadline races the handler promise but the per-exec
2021
+ // AbortSignal it owns reaches cooperative handlers so caller
2022
+ // cancellation and the local deadline can actually stop work.
2023
+ await runWithCursorExecDeadline(
2024
+ run,
2025
+ options?.signal,
2026
+ cursorExecDeadlineMsForTest(idleTimeoutMs),
2027
+ settlement => {
2028
+ if (!reserveCursorMutationLock(conversationId)) {
2029
+ throw new CursorExecAdmissionClosedError(
2030
+ "Cursor non-abortable mutation capacity exhausted",
2031
+ );
2032
+ }
2033
+ mutationSlotReserved = true;
2034
+ pendingNonAbortableExec = settlement;
2035
+ const lock = settlement.settled.then(
2036
+ () => undefined,
2037
+ () => undefined,
2038
+ );
2039
+ registerCursorMutationLock(conversationId, lock);
2040
+ releaseCursorMutationLockReservation(conversationId);
2041
+ mutationSlotReserved = false;
2042
+ },
2043
+ () => {
2044
+ if (mutationSlotReserved) {
2045
+ releaseCursorMutationLockReservation(conversationId);
2046
+ mutationSlotReserved = false;
2047
+ }
2048
+ },
2049
+ () => {
2050
+ activeExecAbort = undefined;
2051
+ if (mutationSlotReserved) {
2052
+ releaseCursorMutationLockReservation(conversationId);
2053
+ mutationSlotReserved = false;
2054
+ }
2055
+ },
2056
+ abort => {
2057
+ activeExecAbort = abort;
2058
+ },
2059
+ () => transportTerminalized,
2060
+ );
2061
+ } else {
2062
+ await run();
2063
+ }
2064
+ execSucceeded = true;
2065
+ } finally {
2066
+ if (isExecutable) {
2067
+ execInFlight = false;
2068
+ if (
2069
+ execSucceeded &&
2070
+ !transportWatchdogClosed &&
2071
+ !callerAbortError &&
2072
+ terminalAdmissionMode !== "closed"
2073
+ ) {
2074
+ processingPausedForExec = false;
2075
+ h2Request!.resume();
2076
+ if (isMeaningful) refreshTransportWatchdog();
2077
+ processPendingBuffer?.();
2078
+ } else if (transportTerminalized || terminalAdmissionMode === "closed") {
2079
+ // A queued exec may be dropped after a trailer/reset or another
2080
+ // earlier terminal closes admission. Do not leave parser state
2081
+ // permanently paused while that dropped promise accounts down.
2082
+ processingPausedForExec = false;
2083
+ if (terminalBoundaryObserved || terminalBoundarySeen) h2Request!.resume();
2084
+ processPendingBuffer?.();
2085
+ }
2086
+ }
2087
+ }
2088
+ }, consumedFrameLength);
2089
+ void queued.catch(() => {});
2090
+ // Terminal bookkeeping belongs to the validated frame boundary,
2091
+ // before parser backpressure can break this loop. The queued handler
2092
+ // still runs in order, while settlement waits for queue drain.
2093
+ if (isTurnEnded) {
2094
+ sawTurnEnded = true;
2095
+ // Make the boundary durable before inspecting the next frame's
2096
+ // header. A malformed or oversized coalesced tail is not allowed
2097
+ // to replace this validated terminal success.
2098
+ terminalBoundarySeen = true;
2099
+ terminalBoundaryObserved = false;
2100
+ closeTerminalAdmission(false);
2101
+ drainMessageQueue();
2102
+ }
2103
+ // A single HTTP/2 data chunk can contain hundreds of valid,
2104
+ // inexpensive Connect frames. Stop parsing at the queue bound and
2105
+ // resume after the ordered chain drains instead of rejecting the
2106
+ // 257th frame before any queued microtask can decrement pending.
2107
+ if (!isExecServerMessage && messageQueue.pending() >= CURSOR_MAX_PENDING_SERVER_MESSAGES) {
2108
+ processingPausedForQueue = true;
2109
+ if (!terminalBoundaryObserved && !terminalBoundarySeen) h2Request!.pause();
2110
+ const resumeAfterDrain = () => {
2111
+ processingPausedForQueue = false;
2112
+ if (processingPausedForExec || callerAbortError) return;
2113
+ if (transportWatchdogClosed && !terminalBoundaryObserved && !terminalBoundarySeen) return;
2114
+ if (terminalAdmissionMode !== "closed" || terminalBoundaryObserved || terminalBoundarySeen)
2115
+ h2Request!.resume();
2116
+ // A lookahead turnEnded closes admission before the validated
2117
+ // prefix reaches the queue bound. Continue parsing that prefix
2118
+ // without reopening transport or admitting tail execs.
2119
+ if (
2120
+ !transportTerminalized ||
2121
+ terminalDrainMode ||
2122
+ terminalBoundaryObserved ||
2123
+ terminalBoundarySeen
2124
+ )
2125
+ processPendingBuffer?.();
2126
+ };
2127
+ // Consume both outcomes: `finally()` would create a second rejected
2128
+ // promise when the boundary handler fails, even though the queue's
2129
+ // normal error path already consumed the original rejection.
2130
+ void messageQueue.drain().then(resumeAfterDrain, resumeAfterDrain);
2131
+ break;
897
2132
  }
898
- // Serialize handlers: exec messages can be asynchronous, and resolving the
899
- // request on turnEnded before prior handlers finish loses their responses.
900
- if (!coordinator.canAdmitTask()) continue;
901
- coordinator.admit(() =>
902
- handleServerMessage(
903
- serverMessage,
904
- output,
905
- stream,
906
- state,
907
- blobStore,
908
- coordinator,
909
- options?.execHandlers,
910
- options?.onToolResult,
911
- usageState,
912
- requestContextTools,
913
- onConversationCheckpoint,
914
- requestContextRules,
915
- ),
916
- );
917
2133
 
918
- if (isTurnEnded) {
919
- coordinator.turnEnded();
2134
+ if (isExecutable) {
2135
+ observeBufferedTerminal(responseEnded);
2136
+ break;
920
2137
  }
921
2138
  } catch (e) {
922
2139
  log("error", "parseServerMessage", { error: String(e) });
2140
+ terminalize(e);
2141
+ break;
923
2142
  }
924
2143
  }
2144
+ // HTTP/2 can emit `end` while a coalesced chunk still has frames
2145
+ // parked behind queue backpressure. Drain only after every buffered
2146
+ // frame has been parsed into the ordered queue.
2147
+ finishResponseAfterParsing();
2148
+ };
2149
+
2150
+ h2Request.on("end", () => {
2151
+ responseEnded = true;
2152
+ if (endStreamError && !h2Settled) {
2153
+ terminalize(endStreamError);
2154
+ return;
2155
+ }
2156
+ if (observeBufferedTerminal(true)) {
2157
+ if (terminalDrainMode) {
2158
+ processPendingBuffer?.();
2159
+ terminalDrain?.();
2160
+ }
2161
+ return;
2162
+ }
2163
+ if (transportTerminalized) return;
2164
+ if (!sawTurnEnded && !h2Settled) {
2165
+ terminalize(
2166
+ pendingBuffer.length > 0
2167
+ ? new Error("Cursor HTTP/2 stream ended before turnEnded")
2168
+ : new Error("Cursor stream ended before turnEnded"),
2169
+ "drainable",
2170
+ );
2171
+ return;
2172
+ }
2173
+ sealExecAdmissionAtRawEof();
2174
+ processPendingBuffer?.();
2175
+ finishResponseAfterParsing();
925
2176
  });
926
2177
 
927
- coordinator.enqueue(frameConnectMessage(requestBytes));
2178
+ h2Request.on("data", (chunk: Buffer) => {
2179
+ if (terminalBoundarySeen) {
2180
+ const remaining = CURSOR_MAX_PENDING_SERVER_BYTES - pendingBuffer.length;
2181
+ if (remaining > 0) pendingBuffer.append(chunk.subarray(0, remaining));
2182
+ refreshPostTurnEndedGrace();
2183
+ processPendingBuffer?.();
2184
+ return;
2185
+ }
2186
+ let offset = 0;
2187
+ while (
2188
+ offset < chunk.length &&
2189
+ !h2Settled &&
2190
+ !transportTerminalized &&
2191
+ !terminalBoundarySeen &&
2192
+ !terminalBoundaryObserved
2193
+ ) {
2194
+ const available = CURSOR_MAX_PENDING_SERVER_BYTES - pendingBuffer.length;
2195
+ if (available <= 0) {
2196
+ processPendingBuffer?.();
2197
+ if (h2Settled) return;
2198
+ const error = new Error("Cursor HTTP/2 response exceeded the maximum pending byte length");
2199
+ endStreamError = error;
2200
+ terminalize(error);
2201
+ return;
2202
+ }
2203
+ const length = Math.min(available, chunk.length - offset);
2204
+ pendingBuffer.append(chunk.subarray(offset, offset + length));
2205
+ offset += length;
2206
+ processPendingBuffer?.();
2207
+ }
2208
+ });
2209
+
2210
+ if (callerAbortError) throw callerAbortError;
2211
+ if (h2Settled) {
2212
+ await h2Completion.promise;
2213
+ }
2214
+ writeCursorFrame(writer, frameConnectMessage(requestBytes));
928
2215
 
929
2216
  const sendHeartbeat = () => {
930
- if (!coordinator.isActive()) return;
2217
+ if (h2Settled || isClosedCursorRequest(writer)) return;
931
2218
  const heartbeatMessage = create(AgentClientMessageSchema, {
932
2219
  message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) },
933
2220
  });
934
2221
  const heartbeatBytes = toBinary(AgentClientMessageSchema, heartbeatMessage);
935
- coordinator.enqueue(frameConnectMessage(heartbeatBytes));
2222
+ writeCursorFrame(writer, frameConnectMessage(heartbeatBytes));
936
2223
  };
937
2224
 
938
2225
  heartbeatTimer = setInterval(sendHeartbeat, 5000);
939
- await new Promise<void>((resolve, reject) => {
940
- resolveH2 = resolve;
941
- rejectH2 = reject;
942
- const initialFailure = coordinator.failureError();
943
- if (initialFailure) {
944
- rejectH2 = undefined;
945
- reject(initialFailure);
946
- } else if (coordinator.hasTurnEnded() && !coordinator.isActive()) {
947
- resolveH2 = undefined;
948
- resolve();
949
- }
950
- });
951
- armInboundTimeout();
952
- await inboundEnd.promise;
953
- await Promise.all(checkpointTasks);
2226
+ // The watchdog was armed before setup; never restart it after request creation.
2227
+ await h2Completion.promise;
2228
+ // A successful terminal frame can settle before the HTTP/2 writer callback.
2229
+ // Bound this final drain so a peer that stopped reading cannot hold the
2230
+ // request open forever, and surface asynchronous callback failures as a
2231
+ // failed request instead of publishing a false successful result.
2232
+ await waitForCursorWrites(h2Request, CURSOR_WRITE_DRAIN_TIMEOUT_MS, forceCloseTransport);
954
2233
 
955
2234
  if (state.currentTextBlock) {
956
2235
  const idx = output.content.indexOf(state.currentTextBlock);
@@ -985,39 +2264,33 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
985
2264
  }
986
2265
 
987
2266
  finalizeCursorUsage(output, usageState);
988
- const stateToCommit =
989
- usageState.pendingCheckpoint ?? conversationStateCache.get(conversationId) ?? conversationState;
990
- if (
991
- usageState.pendingCheckpoint ||
992
- usageState.hasConversationCheckpoint ||
993
- usageState.conversationUsedTokens > 0
994
- ) {
995
- conversationStateCache.set(
996
- conversationId,
997
- create(ConversationStateStructureSchema, {
998
- ...stateToCommit,
999
- ...(usageState.hasConversationCheckpoint || usageState.conversationUsedTokens > 0
1000
- ? {
1001
- tokenDetails: create(ConversationTokenDetailsSchema, {
1002
- usedTokens: output.usage.totalTokens,
1003
- maxTokens: stateToCommit.tokenDetails?.maxTokens ?? 0,
1004
- }),
1005
- }
1006
- : {}),
1007
- }),
1008
- );
2267
+ if (options?.onPayload === undefined && conversationCache.get(conversationId) === previousCacheEntry) {
2268
+ const checkpointState = pendingConversationCheckpoint ?? conversationState;
2269
+ const stateToCommit =
2270
+ usageState.hasConversationCheckpoint || usageState.conversationUsedTokens > 0
2271
+ ? create(ConversationStateStructureSchema, {
2272
+ ...checkpointState,
2273
+ tokenDetails: create(ConversationTokenDetailsSchema, {
2274
+ usedTokens: output.usage.totalTokens,
2275
+ maxTokens: checkpointState.tokenDetails?.maxTokens ?? 0,
2276
+ }),
2277
+ })
2278
+ : checkpointState;
2279
+ conversationCache.set(conversationId, {
2280
+ state: stateToCommit,
2281
+ blobs: blobStore,
2282
+ context: {
2283
+ ...conversationContext,
2284
+ messageKeys: [...conversationContext.messageKeys, hashCursorConversationMessage(output)],
2285
+ },
2286
+ });
1009
2287
  touchCursorConversation(conversationId);
1010
2288
  }
1011
- conversationUsageContextCache.set(conversationId, {
1012
- ...usageContext,
1013
- messageKeys: [...usageContext.messageKeys, hashCursorUsageMessage(output)],
1014
- });
1015
- conversationBlobStores.set(conversationId, blobStore);
1016
- touchCursorConversation(conversationId);
1017
2289
  calculateCost(model, output.usage);
1018
2290
 
1019
2291
  output.duration = Date.now() - startTime;
1020
2292
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
2293
+ completedSuccessfully = true;
1021
2294
  stream.push({
1022
2295
  type: "done",
1023
2296
  reason: output.stopReason as "stop" | "length" | "toolUse",
@@ -1025,34 +2298,81 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
1025
2298
  });
1026
2299
  stream.end();
1027
2300
  } catch (error) {
1028
- if (activeConversationId) {
1029
- if (previousConversationState) conversationStateCache.set(activeConversationId, previousConversationState);
1030
- else conversationStateCache.delete(activeConversationId);
1031
- if (previousUsageContext) conversationUsageContextCache.set(activeConversationId, previousUsageContext);
1032
- else conversationUsageContextCache.delete(activeConversationId);
1033
- }
1034
2301
  // Keep the completion promise terminal even for synchronous setup/write
1035
2302
  // failures that may not emit a separate HTTP/2 error event.
1036
- const mappedError = mapH2TransportError(coordinator?.failureError() ?? error, baseUrl);
1037
- output.stopReason = options?.signal?.aborted ? "aborted" : "error";
2303
+ if (!h2Settled) terminalize(error);
2304
+ // Caller cancellation remains authoritative even when a transport event
2305
+ // rejected h2Completion first; the abort listener can run while the
2306
+ // settlement fence is awaiting a detached mutation.
2307
+ const mappedError = callerAbortError ?? h2Failure ?? error;
2308
+ output.stopReason = callerAbortError || options?.signal?.aborted ? "aborted" : "error";
1038
2309
  output.errorStatus = extractHttpStatusFromError(mappedError);
2310
+ output.transportFailure = transportFailureFacts(mappedError);
1039
2311
  output.errorMessage = formatErrorMessageWithRetryAfter(mappedError);
2312
+ finalizeCursorUsage(output, usageState);
2313
+ calculateCost(model, output.usage);
1040
2314
  output.duration = Date.now() - startTime;
1041
2315
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
1042
2316
  stream.push({ type: "error", reason: output.stopReason, error: output });
1043
2317
  stream.end();
1044
2318
  } finally {
2319
+ options?.signal?.removeEventListener("abort", onCallerAbort);
2320
+ if (gracefulCloseCheckTimer) {
2321
+ clearTimeout(gracefulCloseCheckTimer);
2322
+ gracefulCloseCheckTimer = undefined;
2323
+ }
2324
+ pendingBuffer.clear();
2325
+ bufferedObservationOffset = 0;
2326
+ bufferedObservationTurnEnded = false;
2327
+ bufferedTerminalBoundaryOffset = undefined;
2328
+ terminalBoundaryObserved = false;
2329
+ transportWatchdogClosed = true;
2330
+ if (transportWatchdog) {
2331
+ clearTimeout(transportWatchdog);
2332
+ transportWatchdog = null;
2333
+ }
1045
2334
  if (heartbeatTimer) {
1046
2335
  clearInterval(heartbeatTimer);
1047
2336
  heartbeatTimer = null;
1048
2337
  }
1049
- if (options?.signal && onAbort) {
1050
- options.signal.removeEventListener("abort", onAbort);
2338
+ if (postTurnEndedCheckpointTimer) {
2339
+ clearTimeout(postTurnEndedCheckpointTimer);
2340
+ postTurnEndedCheckpointTimer = undefined;
1051
2341
  }
1052
- if (h2Request && !h2Request.closed && !h2Request.destroyed) {
1053
- h2Request.end();
2342
+ if (h2Request && h2RequestErrorHandler) {
2343
+ h2Request.removeListener("error", h2RequestErrorHandler);
2344
+ }
2345
+ if (h2Client && h2ClientErrorHandler) {
2346
+ // Keep a listener installed while the session closes. Node treats a late
2347
+ // ClientHttp2Session error without listeners as an uncaught exception.
2348
+ h2Client.on("error", () => {});
2349
+ h2Client.removeListener("error", h2ClientErrorHandler);
2350
+ }
2351
+ // A queued exec handler can still be draining when the caller aborts; its
2352
+ // late writes must fail quietly on the closed stream instead of crashing
2353
+ // the process with ERR_STREAM_WRITE_AFTER_END.
2354
+ h2Request?.on("error", () => {});
2355
+ // `write()` only queues the frame. Await each accepted frame's completion
2356
+ // callback before tearing down a successful HTTP/2 request, otherwise the
2357
+ // final exec response can be lost when close wins the writer race.
2358
+ await waitForCursorWrites(h2Request, CURSOR_WRITE_DRAIN_TIMEOUT_MS, forceCloseTransport).catch(() => {});
2359
+ if (completedSuccessfully) {
2360
+ const requestEnded = h2Request
2361
+ ? isClosedCursorRequest(h2Request) || (await endCursorRequestForTest(h2Request))
2362
+ : true;
2363
+ if (!requestEnded) forceCloseTransport();
2364
+ h2Client?.close();
2365
+ // A valid turnEnded can arrive before the peer closes its response half.
2366
+ // Send END_STREAM first; only force cleanup after a bounded grace period
2367
+ // when the peer leaves the completed stream open indefinitely.
2368
+ if (requestEnded && h2Request && !h2Request.closed && !h2Request.destroyed) {
2369
+ const gracefulTeardownTimer = setTimeout(forceCloseTransport, 100);
2370
+ h2Request.once("close", () => clearTimeout(gracefulTeardownTimer));
2371
+ }
2372
+ } else {
2373
+ h2Request?.close();
2374
+ h2Client?.close();
1054
2375
  }
1055
- h2Client?.close();
1056
2376
  proxiedSocket?.destroy();
1057
2377
  }
1058
2378
  })();
@@ -1064,7 +2384,7 @@ type ToolCallState = ToolCall & {
1064
2384
  index: number;
1065
2385
  partialJson?: string;
1066
2386
  kind: "mcp" | "todo_write" | "native" | "cursor-exec";
1067
- [kCursorExecResolved]?: true;
2387
+ [kProviderResolvedToolCall]?: true;
1068
2388
  };
1069
2389
 
1070
2390
  interface BlockState {
@@ -1080,24 +2400,9 @@ interface BlockState {
1080
2400
 
1081
2401
  interface UsageState {
1082
2402
  sawTokenDelta: boolean;
1083
- /**
1084
- * Latest `ConversationTokenDetails.used_tokens`: the whole conversation's
1085
- * token consumption as counted by Cursor, not this turn's output.
1086
- */
1087
2403
  conversationUsedTokens: number;
1088
- /** Output tokens already included in the latest checkpoint snapshot. */
1089
2404
  checkpointOutputTokens: number;
1090
- /** Whether the current stream received a checkpoint, including an explicit zero. */
1091
2405
  hasConversationCheckpoint: boolean;
1092
- pendingCheckpoint?: ConversationStateStructure;
1093
- }
1094
-
1095
- interface CursorUsageContext {
1096
- modelKey: string;
1097
- systemPromptKey: string;
1098
- customSystemPromptKey: string;
1099
- toolsKey: string;
1100
- messageKeys: string[];
1101
2406
  }
1102
2407
 
1103
2408
  async function handleServerMessage(
@@ -1113,6 +2418,8 @@ async function handleServerMessage(
1113
2418
  requestContextTools: McpToolDefinition[],
1114
2419
  onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void,
1115
2420
  requestContextRules: CursorRule[] = [],
2421
+ execSignal?: AbortSignal,
2422
+ markNonAbortable?: () => void,
1116
2423
  ): Promise<void> {
1117
2424
  const msgCase = msg.message.case;
1118
2425
 
@@ -1132,6 +2439,8 @@ async function handleServerMessage(
1132
2439
  output,
1133
2440
  stream,
1134
2441
  requestContextRules,
2442
+ execSignal,
2443
+ markNonAbortable,
1135
2444
  );
1136
2445
  } else if (msgCase === "conversationCheckpointUpdate") {
1137
2446
  handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
@@ -1164,19 +2473,21 @@ function handleKvServerMessage(
1164
2473
  });
1165
2474
 
1166
2475
  const responseBytes = toBinary(AgentClientMessageSchema, kvClientMessage);
1167
- writer.enqueue(frameConnectMessage(responseBytes));
2476
+ writeCursorFrame(writer, frameConnectMessage(responseBytes));
1168
2477
 
1169
2478
  log("kvClient", "getBlobResult", { blobId: blobIdKey.slice(0, 40) });
1170
2479
  } else if (kvCase === "setBlobArgs") {
1171
2480
  const { blobId, blobData } = kvMsg.message.value;
1172
2481
  const blobIdKey = Buffer.from(blobId).toString("hex");
1173
- blobStore.set(blobIdKey, blobData);
2482
+ const stored = blobId.byteLength === CURSOR_BLOB_ID_BYTES && putCursorBlob(blobStore, blobIdKey, blobData);
1174
2483
 
1175
2484
  const response = create(KvClientMessageSchema, {
1176
2485
  id: kvMsg.id,
1177
2486
  message: {
1178
2487
  case: "setBlobResult",
1179
- value: create(SetBlobResultSchema, {}),
2488
+ value: create(SetBlobResultSchema, {
2489
+ error: stored ? undefined : { message: "Cursor blob store exceeded its bounded capacity" },
2490
+ }),
1180
2491
  },
1181
2492
  });
1182
2493
 
@@ -1185,12 +2496,61 @@ function handleKvServerMessage(
1185
2496
  });
1186
2497
 
1187
2498
  const responseBytes = toBinary(AgentClientMessageSchema, kvClientMessage);
1188
- writer.enqueue(frameConnectMessage(responseBytes));
2499
+ writeCursorFrame(writer, frameConnectMessage(responseBytes));
1189
2500
 
1190
2501
  log("kvClient", "setBlobResult", { blobId: blobIdKey.slice(0, 40) });
1191
2502
  }
1192
2503
  }
1193
2504
 
2505
+ /**
2506
+ * Insert a blob into the conversation store under a byte budget, shedding the
2507
+ * least recently written entries when the budget is exceeded.
2508
+ *
2509
+ * Refusing the write is not an option a conversation can recover from. The
2510
+ * store is carried across turns, so once it is full every later `setBlob`
2511
+ * fails, every tool result that depends on one fails with it, and the session
2512
+ * is dead for the rest of its life — compaction and process restart both
2513
+ * rebuild the same oversized store. A dropped historical blob is an already
2514
+ * modelled `getBlob` miss; a refused write is terminal. Shed instead.
2515
+ *
2516
+ * Only two writes are refused: a blob larger than the entire budget, which can
2517
+ * never be retained, and an invalid identifier (rejected by the caller).
2518
+ */
2519
+ function putCursorBlob(
2520
+ blobStore: Map<string, Uint8Array>,
2521
+ blobId: string,
2522
+ blobData: Uint8Array,
2523
+ limits: { maxBytes: number } = { maxBytes: CURSOR_MAX_BLOB_STORE_BYTES },
2524
+ ): boolean {
2525
+ if (blobData.byteLength > limits.maxBytes) return false;
2526
+ // Re-insert so an overwritten or re-stored blob counts as the newest entry:
2527
+ // Map iteration order is insertion order, which is what eviction walks.
2528
+ blobStore.delete(blobId);
2529
+ blobStore.set(blobId, blobData);
2530
+ let totalBytes = 0;
2531
+ for (const value of blobStore.values()) totalBytes += value.byteLength;
2532
+ if (totalBytes <= limits.maxBytes) return true;
2533
+ for (const [key, value] of blobStore) {
2534
+ if (totalBytes <= limits.maxBytes) break;
2535
+ if (key === blobId) continue;
2536
+ blobStore.delete(key);
2537
+ totalBytes -= value.byteLength;
2538
+ }
2539
+ return true;
2540
+ }
2541
+
2542
+ export function storeCursorBlobForTest(
2543
+ blobStore: Map<string, Uint8Array>,
2544
+ blobId: Uint8Array,
2545
+ blobData: Uint8Array,
2546
+ limits: { maxBytes: number },
2547
+ ): boolean {
2548
+ return (
2549
+ blobId.byteLength === CURSOR_BLOB_ID_BYTES &&
2550
+ putCursorBlob(blobStore, Buffer.from(blobId).toString("hex"), blobData, limits)
2551
+ );
2552
+ }
2553
+
1194
2554
  function sendShellStreamEvent(
1195
2555
  h2Request: CursorRequestWriter,
1196
2556
  execMsg: ExecServerMessage,
@@ -1230,6 +2590,8 @@ async function handleShellStreamArgs(
1230
2590
  h2Request: CursorRequestWriter,
1231
2591
  execHandlers: CursorExecHandlers | undefined,
1232
2592
  onToolResult: CursorToolResultHandler | undefined,
2593
+ execSignal?: AbortSignal,
2594
+ markNonAbortable?: () => void,
1233
2595
  ): Promise<void> {
1234
2596
  const normalizedWorkingDirectory = args.workingDirectory || process.cwd();
1235
2597
  const normalizedArgs: ShellArgs = { ...args, workingDirectory: normalizedWorkingDirectory };
@@ -1249,6 +2611,49 @@ async function handleShellStreamArgs(
1249
2611
  let stdoutBuffer = "";
1250
2612
  let stderrBuffer = "";
1251
2613
  let callbacksOpen = true;
2614
+ let pendingShellWriteBytes = 0;
2615
+ let shellWriteFailure: unknown;
2616
+ let shellWriteChain = Promise.resolve();
2617
+ const queueShellStreamEvent = (event: ShellStream["event"]): void => {
2618
+ if (shellWriteFailure || !callbacksOpen || !h2Request.isActive()) return;
2619
+ const frame = encodeExecClientMessageFrame(execMsg, {
2620
+ case: "shellStream",
2621
+ value: create(ShellStreamSchema, { event }),
2622
+ });
2623
+ if (pendingShellWriteBytes + frame.length > CURSOR_MAX_PENDING_SHELL_WRITE_BYTES) {
2624
+ const error = new Error(
2625
+ `Cursor shell output exceeded ${CURSOR_MAX_PENDING_SHELL_WRITE_BYTES} pending write bytes`,
2626
+ );
2627
+ shellWriteFailure = error;
2628
+ callbacksOpen = false;
2629
+ closeStalledCursorRequest(h2Request);
2630
+ shellWriteChain = shellWriteChain.then(() => {
2631
+ throw error;
2632
+ });
2633
+ shellWriteChain.catch(() => {});
2634
+ return;
2635
+ }
2636
+ pendingShellWriteBytes += frame.length;
2637
+ const queuedWrite = shellWriteChain
2638
+ .then(async () => {
2639
+ if (shellWriteFailure) throw shellWriteFailure;
2640
+ const writable = writeCursorFrame(h2Request, frame);
2641
+ if (writable) return;
2642
+ if (isClosedCursorRequest(h2Request)) {
2643
+ throw new Error("Cursor request closed while forwarding shell output");
2644
+ }
2645
+ await waitForCursorWriteDrain(h2Request);
2646
+ })
2647
+ .finally(() => {
2648
+ pendingShellWriteBytes -= frame.length;
2649
+ });
2650
+ shellWriteChain = queuedWrite;
2651
+ queuedWrite.catch(error => {
2652
+ shellWriteFailure ??= error;
2653
+ callbacksOpen = false;
2654
+ closeStalledCursorRequest(h2Request);
2655
+ });
2656
+ };
1252
2657
  const unregisterShellGate = h2Request.registerShellGate(() => {
1253
2658
  callbacksOpen = false;
1254
2659
  if (stdoutFlushTimer) clearTimeout(stdoutFlushTimer);
@@ -1267,7 +2672,7 @@ async function handleShellStreamArgs(
1267
2672
  const toSend = stdoutBuffer.slice(0, safeEnd);
1268
2673
  const remaining = stdoutBuffer.slice(safeEnd);
1269
2674
  if (toSend) {
1270
- sendShellStreamEvent(h2Request, execMsg, {
2675
+ queueShellStreamEvent({
1271
2676
  case: "stdout",
1272
2677
  value: create(ShellStreamStdoutSchema, { data: sanitizeText(toSend) }),
1273
2678
  });
@@ -1286,7 +2691,7 @@ async function handleShellStreamArgs(
1286
2691
  const toSend = stderrBuffer.slice(0, safeEnd);
1287
2692
  const remaining = stderrBuffer.slice(safeEnd);
1288
2693
  if (toSend) {
1289
- sendShellStreamEvent(h2Request, execMsg, {
2694
+ queueShellStreamEvent({
1290
2695
  case: "stderr",
1291
2696
  value: create(ShellStreamStderrSchema, { data: sanitizeText(toSend) }),
1292
2697
  });
@@ -1349,11 +2754,15 @@ async function handleShellStreamArgs(
1349
2754
  // Falls back to the batch shell handler otherwise.
1350
2755
  const streamHandler = execHandlers?.shellStream?.bind(execHandlers);
1351
2756
  const batchHandler = execHandlers?.shell?.bind(execHandlers);
1352
- const handler = streamHandler ? (shellArgs: ShellArgs) => streamHandler(shellArgs, streamCallbacks) : batchHandler;
2757
+ const handler = streamHandler
2758
+ ? (shellArgs: ShellArgs) => streamHandler(shellArgs, streamCallbacks, execSignal, markNonAbortable)
2759
+ : batchHandler
2760
+ ? (shellArgs: ShellArgs) => batchHandler(shellArgs, execSignal, markNonAbortable)
2761
+ : undefined;
1353
2762
 
1354
2763
  const { execResult } = await resolveExecHandler(
1355
2764
  args as any,
1356
- handler as typeof batchHandler,
2765
+ handler,
1357
2766
  onToolResult,
1358
2767
  toolResult => buildShellResultFromToolResult(normalizedArgs as any, toolResult),
1359
2768
  reason =>
@@ -1372,6 +2781,8 @@ async function handleShellStreamArgs(
1372
2781
  if (stderrFlushTimer) clearTimeout(stderrFlushTimer);
1373
2782
  flushStdout();
1374
2783
  flushStderr();
2784
+ await shellWriteChain;
2785
+ if (shellWriteFailure) throw shellWriteFailure;
1375
2786
 
1376
2787
  sendShellStreamExitFromResult(h2Request, execMsg, sanitizedExecResult, sendBufferedOutput);
1377
2788
  // Cursor can keep the turn pending when it receives only stream deltas.
@@ -1501,6 +2912,8 @@ async function handleExecServerMessage(
1501
2912
  output: AssistantMessage,
1502
2913
  stream: AssistantMessageEventStream,
1503
2914
  requestContextRules: CursorRule[] = [],
2915
+ execSignal?: AbortSignal,
2916
+ markNonAbortable?: () => void,
1504
2917
  ): Promise<void> {
1505
2918
  const execCase = execMsg.message.case;
1506
2919
  log("exec", "dispatch", { execCase, execId: execMsg.execId, hasHandlers: !!execHandlers });
@@ -1542,6 +2955,8 @@ async function handleExecServerMessage(
1542
2955
  toolResult => buildReadResultFromToolResult(args.path, toolResult),
1543
2956
  reason => buildReadRejectedResult(args.path, reason),
1544
2957
  error => buildReadErrorResult(args.path, error),
2958
+ execSignal,
2959
+ markNonAbortable,
1545
2960
  );
1546
2961
  sendExecClientMessage(h2Request, execMsg, "readResult", execResult);
1547
2962
  return;
@@ -1555,6 +2970,8 @@ async function handleExecServerMessage(
1555
2970
  toolResult => buildLsResultFromToolResult(args.path, toolResult),
1556
2971
  reason => buildLsRejectedResult(args.path, reason),
1557
2972
  error => buildLsErrorResult(args.path, error),
2973
+ execSignal,
2974
+ markNonAbortable,
1558
2975
  );
1559
2976
  sendExecClientMessage(h2Request, execMsg, "lsResult", execResult);
1560
2977
  return;
@@ -1568,6 +2985,7 @@ async function handleExecServerMessage(
1568
2985
  toolResult => buildGrepResultFromToolResult(args, toolResult),
1569
2986
  reason => buildGrepErrorResult(reason),
1570
2987
  error => buildGrepErrorResult(error),
2988
+ execSignal,
1571
2989
  );
1572
2990
  sendExecClientMessage(h2Request, execMsg, "grepResult", execResult);
1573
2991
  return;
@@ -1590,6 +3008,8 @@ async function handleExecServerMessage(
1590
3008
  ),
1591
3009
  reason => buildWriteRejectedResult(args.path, reason),
1592
3010
  error => buildWriteErrorResult(args.path, error),
3011
+ execSignal,
3012
+ markNonAbortable,
1593
3013
  );
1594
3014
  sendExecClientMessage(h2Request, execMsg, "writeResult", execResult);
1595
3015
  return;
@@ -1603,6 +3023,8 @@ async function handleExecServerMessage(
1603
3023
  toolResult => buildDeleteResultFromToolResult(args.path, toolResult),
1604
3024
  reason => buildDeleteRejectedResult(args.path, reason),
1605
3025
  error => buildDeleteErrorResult(args.path, error),
3026
+ execSignal,
3027
+ markNonAbortable,
1606
3028
  );
1607
3029
  sendExecClientMessage(h2Request, execMsg, "deleteResult", execResult);
1608
3030
  return;
@@ -1617,6 +3039,8 @@ async function handleExecServerMessage(
1617
3039
  toolResult => buildShellResultFromToolResult(normalizedArgs, toolResult),
1618
3040
  reason => buildShellRejectedResult(normalizedArgs.command, normalizedArgs.workingDirectory, reason),
1619
3041
  error => buildShellFailureResult(normalizedArgs.command, normalizedArgs.workingDirectory, error),
3042
+ execSignal,
3043
+ markNonAbortable,
1620
3044
  );
1621
3045
  const sanitizedExecResult = sanitizeShellExecResult(execResult);
1622
3046
  sendExecClientMessage(h2Request, execMsg, "shellResult", sanitizedExecResult);
@@ -1624,7 +3048,15 @@ async function handleExecServerMessage(
1624
3048
  }
1625
3049
  case "shellStreamArgs": {
1626
3050
  const args = execMsg.message.value;
1627
- await handleShellStreamArgs(args, execMsg, h2Request, execHandlers, onToolResult);
3051
+ await handleShellStreamArgs(
3052
+ args,
3053
+ execMsg,
3054
+ h2Request,
3055
+ execHandlers,
3056
+ onToolResult,
3057
+ execSignal,
3058
+ markNonAbortable,
3059
+ );
1628
3060
  return;
1629
3061
  }
1630
3062
  case "backgroundShellSpawnArgs": {
@@ -1678,6 +3110,7 @@ async function handleExecServerMessage(
1678
3110
  toolResult => buildDiagnosticsResultFromToolResult(args.path, toolResult),
1679
3111
  reason => buildDiagnosticsRejectedResult(args.path, reason),
1680
3112
  error => buildDiagnosticsErrorResult(args.path, error),
3113
+ execSignal,
1681
3114
  );
1682
3115
  sendExecClientMessage(h2Request, execMsg, "diagnosticsResult", execResult);
1683
3116
  return;
@@ -1692,6 +3125,8 @@ async function handleExecServerMessage(
1692
3125
  toolResult => buildMcpResultFromToolResult(mcpCall, toolResult),
1693
3126
  _reason => buildMcpToolNotFoundResult(mcpCall),
1694
3127
  error => buildMcpErrorResult(error),
3128
+ execSignal,
3129
+ markNonAbortable,
1695
3130
  );
1696
3131
  sendExecClientMessage(h2Request, execMsg, "mcpResult", execResult);
1697
3132
  return;
@@ -1722,7 +3157,7 @@ async function handleExecServerMessage(
1722
3157
  synthesizeCursorExecToolCall(output, stream, toolCallId, "read", {
1723
3158
  path: piReadDisplayPath(args.path, args.offset, args.limit),
1724
3159
  });
1725
- const call = { args, toolCallId };
3160
+ const call = { args, toolCallId, signal: execSignal, markNonAbortable };
1726
3161
  const { execResult } = await resolveExecHandler(
1727
3162
  call,
1728
3163
  execHandlers?.piRead?.bind(execHandlers),
@@ -1741,7 +3176,7 @@ async function handleExecServerMessage(
1741
3176
  command: args.command,
1742
3177
  timeout: piTimeout(args.timeout),
1743
3178
  });
1744
- const call = { args, toolCallId };
3179
+ const call = { args, toolCallId, signal: execSignal, markNonAbortable };
1745
3180
  const { execResult } = await resolveExecHandler(
1746
3181
  call,
1747
3182
  execHandlers?.piBash?.bind(execHandlers),
@@ -1760,7 +3195,7 @@ async function handleExecServerMessage(
1760
3195
  path: args.path,
1761
3196
  edits: args.edits.map(edit => ({ old_text: edit.oldText, new_text: edit.newText })),
1762
3197
  });
1763
- const call = { args, toolCallId };
3198
+ const call = { args, toolCallId, signal: execSignal, markNonAbortable };
1764
3199
  const { execResult } = await resolveExecHandler(
1765
3200
  call,
1766
3201
  execHandlers?.piEdit?.bind(execHandlers),
@@ -1779,7 +3214,7 @@ async function handleExecServerMessage(
1779
3214
  path: args.path,
1780
3215
  content: args.content,
1781
3216
  });
1782
- const call = { args, toolCallId };
3217
+ const call = { args, toolCallId, signal: execSignal, markNonAbortable };
1783
3218
  const { execResult } = await resolveExecHandler(
1784
3219
  call,
1785
3220
  execHandlers?.piWrite?.bind(execHandlers),
@@ -1803,7 +3238,7 @@ async function handleExecServerMessage(
1803
3238
  context: args.context,
1804
3239
  limit: piLimit(args.limit),
1805
3240
  });
1806
- const call = { args, toolCallId };
3241
+ const call = { args, toolCallId, signal: execSignal };
1807
3242
  const { execResult } = await resolveExecHandler(
1808
3243
  call,
1809
3244
  execHandlers?.piGrep?.bind(execHandlers),
@@ -1822,7 +3257,7 @@ async function handleExecServerMessage(
1822
3257
  paths: [piJoinPath(args.path, args.pattern)],
1823
3258
  limit: piLimit(args.limit),
1824
3259
  });
1825
- const call = { args, toolCallId };
3260
+ const call = { args, toolCallId, signal: execSignal };
1826
3261
  const { execResult } = await resolveExecHandler(
1827
3262
  call,
1828
3263
  execHandlers?.piFind?.bind(execHandlers),
@@ -1838,7 +3273,7 @@ async function handleExecServerMessage(
1838
3273
  const args = execMsg.message.value;
1839
3274
  const toolCallId = crypto.randomUUID();
1840
3275
  synthesizeCursorExecToolCall(output, stream, toolCallId, "read", { path: piLsPath(args.path) });
1841
- const call = { args, toolCallId };
3276
+ const call = { args, toolCallId, signal: execSignal, markNonAbortable };
1842
3277
  const { execResult } = await resolveExecHandler(
1843
3278
  call,
1844
3279
  execHandlers?.piLs?.bind(execHandlers),
@@ -1918,10 +3353,18 @@ function sendExecClientMessage<TCase extends NonNullable<ExecClientMessage["mess
1918
3353
  messageCase: TCase,
1919
3354
  value: Extract<ExecClientMessage["message"], { case: TCase }>["value"],
1920
3355
  ): void {
3356
+ writeCursorFrame(
3357
+ h2Request,
3358
+ encodeExecClientMessageFrame(execMsg, { case: messageCase, value } as ExecClientMessage["message"]),
3359
+ );
3360
+ log("execClientMessage", messageCase, value);
3361
+ }
3362
+
3363
+ function encodeExecClientMessageFrame(execMsg: ExecServerMessage, message: ExecClientMessage["message"]): Buffer {
1921
3364
  const execClientMessage = create(ExecClientMessageSchema, {
1922
3365
  id: execMsg.id,
1923
3366
  execId: execMsg.execId,
1924
- message: { case: messageCase, value } as ExecClientMessage["message"],
3367
+ message,
1925
3368
  });
1926
3369
 
1927
3370
  const clientMessage = create(AgentClientMessageSchema, {
@@ -1929,9 +3372,7 @@ function sendExecClientMessage<TCase extends NonNullable<ExecClientMessage["mess
1929
3372
  });
1930
3373
 
1931
3374
  const responseBytes = toBinary(AgentClientMessageSchema, clientMessage);
1932
- h2Request.enqueue(frameConnectMessage(responseBytes));
1933
-
1934
- log("execClientMessage", messageCase, value);
3375
+ return frameConnectMessage(responseBytes);
1935
3376
  }
1936
3377
 
1937
3378
  function sendExecClientThrow(
@@ -1949,7 +3390,7 @@ function sendExecClientThrow(
1949
3390
  const clientMessage = create(AgentClientMessageSchema, {
1950
3391
  message: { case: "execClientControlMessage", value: controlMessage },
1951
3392
  });
1952
- h2Request.enqueue(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
3393
+ writeCursorFrame(h2Request, frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
1953
3394
  sendExecClientStreamClose(h2Request, execMsg);
1954
3395
  }
1955
3396
 
@@ -1966,25 +3407,33 @@ function sendExecClientStreamClose(h2Request: CursorRequestWriter, execMsg: Exec
1966
3407
  message: { case: "execClientControlMessage", value: closeMessage },
1967
3408
  });
1968
3409
  const responseBytes = toBinary(AgentClientMessageSchema, clientMessage);
1969
- h2Request.enqueue(frameConnectMessage(responseBytes));
3410
+ writeCursorFrame(h2Request, frameConnectMessage(responseBytes));
1970
3411
  log("execClientControl", "streamClose", { id: execMsg.id, execId: execMsg.execId });
1971
3412
  }
1972
3413
 
1973
3414
  /** Exported for tests: verifies handler is invoked with correct `this` when passed as bound. */
1974
3415
  export async function resolveExecHandler<TArgs, TResult>(
1975
3416
  args: TArgs,
1976
- handler: ((args: TArgs) => Promise<CursorExecHandlerResult<TResult>>) | undefined,
3417
+ handler:
3418
+ | ((
3419
+ args: TArgs,
3420
+ signal?: AbortSignal,
3421
+ markNonAbortable?: () => void,
3422
+ ) => Promise<CursorExecHandlerResult<TResult>>)
3423
+ | undefined,
1977
3424
  onToolResult: CursorToolResultHandler | undefined,
1978
3425
  buildFromToolResult: (toolResult: ToolResultMessage) => TResult,
1979
3426
  buildRejected: (reason: string) => TResult,
1980
3427
  buildError: (error: string) => TResult,
3428
+ signal?: AbortSignal,
3429
+ markNonAbortable?: () => void,
1981
3430
  ): Promise<{ execResult: TResult; toolResult?: ToolResultMessage }> {
1982
3431
  if (!handler) {
1983
3432
  return { execResult: buildRejected("Tool not available") };
1984
3433
  }
1985
3434
 
1986
3435
  try {
1987
- const handlerResult = await handler(args);
3436
+ const handlerResult = await handler(args, signal, markNonAbortable);
1988
3437
  const { execResult, toolResult } = splitExecHandlerResult(handlerResult);
1989
3438
  const finalToolResult = await applyToolResultHandler(toolResult, onToolResult);
1990
3439
 
@@ -1996,28 +3445,73 @@ export async function resolveExecHandler<TArgs, TResult>(
1996
3445
  }
1997
3446
  return { execResult: buildRejected("Tool returned no result") };
1998
3447
  } catch (error) {
3448
+ if (error instanceof CursorExecAdmissionClosedError) throw error;
1999
3449
  const message = error instanceof Error ? error.message : String(error);
2000
3450
  return { execResult: buildError(message) };
2001
3451
  }
2002
3452
  }
2003
3453
 
2004
3454
  /** Exported for deterministic coverage of ordered server-message handling. */
2005
- export function createCursorMessageQueueForTest(onError?: (error: unknown) => void): {
2006
- enqueue(handler: () => void | Promise<void>): Promise<void>;
3455
+ export function createCursorMessageQueueForTest(
3456
+ onError?: (error: unknown) => void,
3457
+ maxPendingBytes = CURSOR_MAX_QUEUED_SERVER_BYTES,
3458
+ ): {
3459
+ enqueue(handler: () => void | Promise<void>, byteSize?: number): Promise<void>;
2007
3460
  drain(): Promise<void>;
3461
+ pending(): number;
3462
+ pendingBytes(): number;
2008
3463
  } {
2009
3464
  let chain = Promise.resolve();
3465
+ let pending = 0;
3466
+ let pendingBytes = 0;
3467
+ let closed = false;
3468
+ let hasAdmittedTask = false;
2010
3469
  return {
2011
- enqueue(handler) {
2012
- const result = chain.then(handler);
2013
- chain = result.catch(error => {
3470
+ enqueue(handler, byteSize = 0) {
3471
+ if (closed) return Promise.reject(new Error("Cursor server-message queue is closed"));
3472
+ if (pending >= CURSOR_MAX_PENDING_SERVER_MESSAGES) {
3473
+ const error = new Error("Cursor server-message queue exceeded its bounded capacity");
3474
+ closed = true;
3475
+ onError?.(error);
3476
+ return Promise.reject(error);
3477
+ }
3478
+ if (byteSize < 0 || pendingBytes + byteSize > maxPendingBytes) {
3479
+ const error = new Error("Cursor server-message queue exceeded its bounded byte capacity");
3480
+ closed = true;
2014
3481
  onError?.(error);
3482
+ return Promise.reject(error);
3483
+ }
3484
+ pending += 1;
3485
+ pendingBytes += byteSize;
3486
+ let result: Promise<void>;
3487
+ if (!hasAdmittedTask) {
3488
+ hasAdmittedTask = true;
3489
+ try {
3490
+ result = Promise.resolve(handler());
3491
+ } catch (error) {
3492
+ result = Promise.reject(error);
3493
+ }
3494
+ } else {
3495
+ result = chain.then(handler);
3496
+ }
3497
+ const accounting = result.finally(() => {
3498
+ pending -= 1;
3499
+ pendingBytes -= byteSize;
2015
3500
  });
2016
- return result;
3501
+ chain = accounting.catch(error => {
3502
+ onError?.(error);
3503
+ });
3504
+ return accounting;
2017
3505
  },
2018
3506
  drain() {
2019
3507
  return chain;
2020
3508
  },
3509
+ pending() {
3510
+ return pending;
3511
+ },
3512
+ pendingBytes() {
3513
+ return pendingBytes;
3514
+ },
2021
3515
  };
2022
3516
  }
2023
3517
 
@@ -2826,7 +4320,7 @@ function synthesizeCursorExecToolCall(
2826
4320
  arguments: cursorJsonSafeValue(args) as Record<string, unknown>,
2827
4321
  index: output.content.length,
2828
4322
  kind: "cursor-exec",
2829
- [kCursorExecResolved]: true,
4323
+ [kProviderResolvedToolCall]: true,
2830
4324
  };
2831
4325
  output.content.push(block);
2832
4326
  const contentIndex = output.content.length - 1;
@@ -2999,32 +4493,20 @@ function handleConversationCheckpointUpdate(
2999
4493
  return;
3000
4494
  }
3001
4495
  const previousUsedTokens = usageState.conversationUsedTokens;
3002
- // `used_tokens` counts the whole conversation, so it is prompt-side usage and
3003
- // must not be attributed to this turn's output. Checkpoints can arrive while
3004
- // output is still streaming; the split is applied once the stream finalizes.
3005
4496
  usageState.conversationUsedTokens = usedTokens;
3006
- usageState.checkpointOutputTokens =
3007
- usageState.hasConversationCheckpoint && usedTokens < previousUsedTokens ? 0 : output.usage.output;
4497
+ usageState.checkpointOutputTokens = usedTokens < previousUsedTokens ? 0 : output.usage.output;
3008
4498
  usageState.hasConversationCheckpoint = true;
3009
4499
  }
3010
4500
 
3011
- /**
3012
- * Cursor streams output tokens as deltas and reports whole-conversation
3013
- * consumption separately as `ConversationTokenDetails.used_tokens`. Derive
3014
- * prompt tokens from the difference so context accounting and compaction see a
3015
- * real prompt size instead of zero.
3016
- */
4501
+ /** Derive prompt usage from Cursor's whole-conversation checkpoint total. */
3017
4502
  export function finalizeCursorUsage(output: AssistantMessage, usageState: UsageState): void {
3018
4503
  const used = usageState.conversationUsedTokens;
3019
- if (!usageState.hasConversationCheckpoint && used <= 0) {
3020
- return;
3021
- }
4504
+ if (!usageState.hasConversationCheckpoint && used <= 0) return;
3022
4505
  const outputIncludedInSnapshot = usageState.hasConversationCheckpoint ? usageState.checkpointOutputTokens : 0;
3023
4506
  output.usage.input = Math.max(0, used - outputIncludedInSnapshot);
3024
4507
  output.usage.totalTokens = output.usage.input + output.usage.output;
3025
4508
  }
3026
4509
 
3027
- /** Exposes {@link finalizeCursorUsage} for tests without a live HTTP/2 stream. */
3028
4510
  export function finalizeCursorUsageForTest(
3029
4511
  usedTokens: number,
3030
4512
  outputTokens: number,
@@ -3054,7 +4536,9 @@ function createBlobId(data: Uint8Array): Uint8Array {
3054
4536
 
3055
4537
  function storeCursorBlob(blobStore: Map<string, Uint8Array>, data: Uint8Array): Uint8Array {
3056
4538
  const blobId = createBlobId(data);
3057
- blobStore.set(Buffer.from(blobId).toString("hex"), data);
4539
+ // Request construction is the larger writer of the two. Charging it to the
4540
+ // same budget is what makes the budget describe the real map.
4541
+ putCursorBlob(blobStore, Buffer.from(blobId).toString("hex"), data);
3058
4542
  return blobId;
3059
4543
  }
3060
4544
 
@@ -3068,26 +4552,40 @@ function readCursorBlob(blobStore: Map<string, Uint8Array>, blobId: Uint8Array):
3068
4552
 
3069
4553
  const CURSOR_NATIVE_TOOL_NAMES = new Set(["bash", "read", "write", "delete", "ls", "grep", "lsp", "todo_write"]);
3070
4554
 
3071
- function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[] {
3072
- if (!tools || tools.length === 0) {
3073
- return [];
3074
- }
4555
+ interface CursorWireToolIdentity {
4556
+ name: string;
4557
+ description: string;
4558
+ inputSchema: JsonValue;
4559
+ }
3075
4560
 
3076
- const advertisedTools = tools.filter(tool => !CURSOR_NATIVE_TOOL_NAMES.has(tool.name));
3077
- if (advertisedTools.length === 0) {
3078
- return [];
3079
- }
4561
+ function buildCursorWireToolIdentities(tools: Tool[] | undefined): CursorWireToolIdentity[] {
4562
+ if (!tools || tools.length === 0) return [];
3080
4563
 
3081
- return advertisedTools.map(tool => {
3082
- const jsonSchema = flattenToolRootCombinators(toolWireSchema(tool));
3083
- const schemaValue: JsonValue =
3084
- jsonSchema && typeof jsonSchema === "object"
3085
- ? (jsonSchema as JsonValue)
3086
- : { type: "object", properties: {}, required: [] };
3087
- const inputSchema = toBinary(ValueSchema, fromJson(ValueSchema, schemaValue));
4564
+ return tools
4565
+ .filter(tool => !CURSOR_NATIVE_TOOL_NAMES.has(tool.name))
4566
+ .map(tool => {
4567
+ const jsonSchema = flattenToolRootCombinators(toolWireSchema(tool));
4568
+ return {
4569
+ name: tool.name,
4570
+ description: tool.description || "",
4571
+ inputSchema:
4572
+ jsonSchema && typeof jsonSchema === "object"
4573
+ ? (jsonSchema as JsonValue)
4574
+ : { type: "object", properties: {}, required: [] },
4575
+ };
4576
+ });
4577
+ }
4578
+
4579
+ function buildCursorUsageToolsKey(tools: Tool[] | undefined): string {
4580
+ return hashCursorConversationValue(buildCursorWireToolIdentities(tools));
4581
+ }
4582
+
4583
+ function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[] {
4584
+ return buildCursorWireToolIdentities(tools).map(tool => {
4585
+ const inputSchema = toBinary(ValueSchema, fromJson(ValueSchema, tool.inputSchema));
3088
4586
  return create(McpToolDefinitionSchema, {
3089
4587
  name: tool.name,
3090
- description: tool.description || "",
4588
+ description: tool.description,
3091
4589
  providerIdentifier: "pi-agent",
3092
4590
  toolName: tool.name,
3093
4591
  inputSchema,
@@ -3368,41 +4866,9 @@ function buildConversationTurns(messages: Message[], blobStore: Map<string, Uint
3368
4866
  return turns;
3369
4867
  }
3370
4868
 
3371
- function buildCursorUsageContext(
3372
- context: Context,
3373
- model: Model<"cursor-agent">,
3374
- options: CursorOptions | undefined,
3375
- ): CursorUsageContext {
3376
- return {
3377
- modelKey: hashCursorUsageValue({ provider: model.provider, id: model.id, wireModelId: model.wireModelId }),
3378
- systemPromptKey: hashCursorUsageValue(context.systemPrompt ?? []),
3379
- customSystemPromptKey: hashCursorUsageValue(options?.customSystemPrompt ?? ""),
3380
- toolsKey: hashCursorUsageValue(context.tools ?? []),
3381
- messageKeys: context.messages.map(message => hashCursorUsageMessage(message)),
3382
- };
3383
- }
3384
-
3385
- function hashCursorUsageMessage(message: { role: string; content: unknown }): string {
3386
- return hashCursorUsageValue({ role: message.role, content: message.content });
3387
- }
3388
-
3389
- function hashCursorUsageValue(value: unknown): string {
3390
- return createHash("sha256")
3391
- .update(JSON.stringify(value) ?? "")
3392
- .digest("hex");
3393
- }
3394
-
3395
- function canReuseCursorUsageContext(previous: CursorUsageContext | undefined, current: CursorUsageContext): boolean {
3396
- if (
3397
- !previous ||
3398
- previous.modelKey !== current.modelKey ||
3399
- previous.systemPromptKey !== current.systemPromptKey ||
3400
- previous.customSystemPromptKey !== current.customSystemPromptKey ||
3401
- previous.toolsKey !== current.toolsKey
3402
- )
3403
- return false;
3404
- if (previous.messageKeys.length > current.messageKeys.length) return false;
3405
- return previous.messageKeys.every((key, index) => key === current.messageKeys[index]);
4869
+ /** Exported for regression coverage of the tool usage-cache identity boundary. */
4870
+ export function buildCursorUsageToolsKeyForTest(tools: Tool[]): string {
4871
+ return buildCursorUsageToolsKey(tools);
3406
4872
  }
3407
4873
 
3408
4874
  /** Exported for tests: decodes Cursor history blobs built from conversation messages. */
@@ -3459,6 +4925,56 @@ function extractImages(content: (TextContent | ImageContent)[]) {
3459
4925
  );
3460
4926
  }
3461
4927
 
4928
+ function buildCursorConversationContext(
4929
+ context: Context,
4930
+ model: Model<"cursor-agent">,
4931
+ options: CursorOptions | undefined,
4932
+ baseUrl: string,
4933
+ apiKey: string,
4934
+ ): CursorConversationContext {
4935
+ return {
4936
+ endpointKey: hashCursorConversationValue(baseUrl),
4937
+ credentialKey: hashCursorConversationValue({ apiKey, authCredentialType: options?.authCredentialType }),
4938
+ modelKey: hashCursorConversationValue({
4939
+ provider: model.provider,
4940
+ id: model.id,
4941
+ wireModelId: model.wireModelId,
4942
+ }),
4943
+ systemPromptKey: hashCursorConversationValue(context.systemPrompt ?? []),
4944
+ customSystemPromptKey: hashCursorConversationValue(options?.customSystemPrompt ?? ""),
4945
+ toolsKey: buildCursorUsageToolsKey(context.tools),
4946
+ messageKeys: context.messages.map(hashCursorConversationMessage),
4947
+ };
4948
+ }
4949
+
4950
+ function hashCursorConversationMessage(message: { role: string; content: unknown }): string {
4951
+ return hashCursorConversationValue({ role: message.role, content: message.content });
4952
+ }
4953
+
4954
+ function hashCursorConversationValue(value: unknown): string {
4955
+ return createHash("sha256")
4956
+ .update(JSON.stringify(value) ?? "")
4957
+ .digest("hex");
4958
+ }
4959
+
4960
+ function canReuseCursorConversationContext(
4961
+ previous: CursorConversationContext,
4962
+ current: CursorConversationContext,
4963
+ ): boolean {
4964
+ if (
4965
+ previous.endpointKey !== current.endpointKey ||
4966
+ previous.credentialKey !== current.credentialKey ||
4967
+ previous.modelKey !== current.modelKey ||
4968
+ previous.systemPromptKey !== current.systemPromptKey ||
4969
+ previous.customSystemPromptKey !== current.customSystemPromptKey ||
4970
+ previous.toolsKey !== current.toolsKey ||
4971
+ previous.messageKeys.length > current.messageKeys.length
4972
+ ) {
4973
+ return false;
4974
+ }
4975
+ return previous.messageKeys.every((key, index) => key === current.messageKeys[index]);
4976
+ }
4977
+
3462
4978
  async function buildGrpcRequest(
3463
4979
  model: Model<"cursor-agent">,
3464
4980
  context: Context,