cursor-opencode-provider 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +230 -44
  3. package/SECURITY.md +60 -12
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +23 -4
  14. package/dist/context/build.js +65 -10
  15. package/dist/context/dynamic-catalog.js +6 -1
  16. package/dist/context/env.js +27 -3
  17. package/dist/context/epoch.js +9 -5
  18. package/dist/context/frozen.js +6 -2
  19. package/dist/context/git.d.ts +5 -2
  20. package/dist/context/git.js +17 -7
  21. package/dist/context/index.d.ts +2 -0
  22. package/dist/context/index.js +2 -0
  23. package/dist/context/paths.d.ts +45 -18
  24. package/dist/context/paths.js +72 -22
  25. package/dist/context/skills-bridge.d.ts +22 -0
  26. package/dist/context/skills-bridge.js +20 -0
  27. package/dist/context/skills.d.ts +34 -0
  28. package/dist/context/skills.js +211 -0
  29. package/dist/debug.js +13 -2
  30. package/dist/early-tool-rows.d.ts +60 -0
  31. package/dist/early-tool-rows.js +96 -0
  32. package/dist/errors.d.ts +49 -7
  33. package/dist/errors.js +163 -8
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +117 -13
  36. package/dist/host-interrupt.d.ts +12 -0
  37. package/dist/host-interrupt.js +18 -0
  38. package/dist/host-plan-file.d.ts +28 -0
  39. package/dist/host-plan-file.js +76 -0
  40. package/dist/host-steer.d.ts +71 -0
  41. package/dist/host-steer.js +232 -0
  42. package/dist/image-input.d.ts +18 -0
  43. package/dist/image-input.js +122 -30
  44. package/dist/image-save.d.ts +10 -2
  45. package/dist/image-save.js +33 -21
  46. package/dist/index.d.ts +2 -2
  47. package/dist/language-model.d.ts +149 -30
  48. package/dist/language-model.js +3127 -673
  49. package/dist/lost-request.d.ts +39 -0
  50. package/dist/lost-request.js +111 -0
  51. package/dist/model-config.d.ts +12 -1
  52. package/dist/model-config.js +78 -30
  53. package/dist/models.d.ts +26 -3
  54. package/dist/models.js +101 -45
  55. package/dist/opencode2/catalog.d.ts +1 -0
  56. package/dist/opencode2/catalog.js +2 -0
  57. package/dist/opencode2/image-save-tool.d.ts +8 -0
  58. package/dist/opencode2/image-save-tool.js +84 -0
  59. package/dist/opencode2/tool-results.d.ts +18 -0
  60. package/dist/opencode2/tool-results.js +66 -0
  61. package/dist/opencode2/types.d.ts +15 -0
  62. package/dist/parallel-step.d.ts +65 -0
  63. package/dist/parallel-step.js +102 -0
  64. package/dist/plugin-opencode2.js +233 -65
  65. package/dist/plugin.js +97 -23
  66. package/dist/pricing-data.d.ts +40 -3
  67. package/dist/pricing-data.js +40 -3
  68. package/dist/pricing.d.ts +1 -1
  69. package/dist/pricing.js +7 -4
  70. package/dist/process-shared.d.ts +14 -0
  71. package/dist/process-shared.js +18 -0
  72. package/dist/protocol/apply-patch.d.ts +25 -13
  73. package/dist/protocol/apply-patch.js +79 -30
  74. package/dist/protocol/ask-question.d.ts +12 -1
  75. package/dist/protocol/ask-question.js +77 -3
  76. package/dist/protocol/blob-store.d.ts +5 -1
  77. package/dist/protocol/blob-store.js +10 -6
  78. package/dist/protocol/client-version.js +6 -4
  79. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  80. package/dist/protocol/conversation-cache-header.js +149 -0
  81. package/dist/protocol/conversation-persistence.d.ts +27 -1
  82. package/dist/protocol/conversation-persistence.js +628 -126
  83. package/dist/protocol/conversation-state.d.ts +8 -1
  84. package/dist/protocol/conversation-state.js +28 -7
  85. package/dist/protocol/create-plan.d.ts +47 -25
  86. package/dist/protocol/create-plan.js +33 -23
  87. package/dist/protocol/host-notes.d.ts +31 -0
  88. package/dist/protocol/host-notes.js +85 -0
  89. package/dist/protocol/interactions.d.ts +26 -17
  90. package/dist/protocol/interactions.js +80 -31
  91. package/dist/protocol/messages.js +164 -21
  92. package/dist/protocol/request.d.ts +40 -19
  93. package/dist/protocol/request.js +55 -28
  94. package/dist/protocol/sent-history-images.d.ts +49 -0
  95. package/dist/protocol/sent-history-images.js +102 -0
  96. package/dist/protocol/switch-mode.d.ts +66 -27
  97. package/dist/protocol/switch-mode.js +147 -73
  98. package/dist/protocol/token-details.d.ts +2 -0
  99. package/dist/protocol/token-details.js +11 -1
  100. package/dist/protocol/tool-call-bridge.d.ts +6 -0
  101. package/dist/protocol/tool-call-bridge.js +24 -0
  102. package/dist/protocol/tools.d.ts +90 -9
  103. package/dist/protocol/tools.js +543 -151
  104. package/dist/protocol/turn-provenance.d.ts +19 -4
  105. package/dist/protocol/turn-provenance.js +77 -20
  106. package/dist/replay-safety.d.ts +22 -4
  107. package/dist/replay-safety.js +86 -27
  108. package/dist/retry-budget.d.ts +21 -0
  109. package/dist/retry-budget.js +94 -0
  110. package/dist/runtime-files.d.ts +23 -0
  111. package/dist/runtime-files.js +581 -0
  112. package/dist/session.d.ts +129 -10
  113. package/dist/session.js +198 -35
  114. package/dist/shared.d.ts +1 -1
  115. package/dist/shared.js +1 -1
  116. package/dist/shell-timeout.d.ts +54 -10
  117. package/dist/shell-timeout.js +362 -92
  118. package/dist/stream-write.d.ts +20 -0
  119. package/dist/stream-write.js +87 -0
  120. package/dist/terminal-runner.d.ts +15 -0
  121. package/dist/terminal-runner.js +528 -0
  122. package/dist/todo-store.d.ts +2 -0
  123. package/dist/todo-store.js +19 -0
  124. package/dist/transport/connect.d.ts +10 -1
  125. package/dist/transport/connect.js +124 -27
  126. package/dist/usage.d.ts +37 -17
  127. package/dist/usage.js +85 -34
  128. package/package.json +5 -3
  129. package/dist/plan-execution-kickoff.d.ts +0 -57
  130. package/dist/plan-execution-kickoff.js +0 -186
  131. package/dist/protocol/thinking.d.ts +0 -15
  132. package/dist/protocol/thinking.js +0 -17
@@ -54,6 +54,25 @@ export function normalizeSessionTodos(value) {
54
54
  }
55
55
  return out;
56
56
  }
57
+ /** Fill omitted defaults before validation without dropping malformed entries. */
58
+ export function normalizeOpencodeTodoArgs(args) {
59
+ if (!Array.isArray(args.todos))
60
+ return args;
61
+ return {
62
+ ...args,
63
+ todos: args.todos.map((item, index) => {
64
+ if (!item || typeof item !== "object" || Array.isArray(item))
65
+ return item;
66
+ const todo = item;
67
+ return {
68
+ ...todo,
69
+ ...(todo.id === undefined ? { id: String(index + 1) } : {}),
70
+ ...(todo.status === undefined ? { status: "pending" } : {}),
71
+ ...(todo.priority === undefined ? { priority: "medium" } : {}),
72
+ };
73
+ }),
74
+ };
75
+ }
57
76
  export function getSessionTodos(sessionID) {
58
77
  return lists.get(sessionID)?.map((todo) => ({ ...todo })) ?? [];
59
78
  }
@@ -35,7 +35,12 @@ export declare function fetchAgentUrl(token: string, options?: {
35
35
  }): Promise<string>;
36
36
  export type BidiStream = {
37
37
  write(msg: Uint8Array): boolean | void;
38
- waitForDrain?(timeoutMs: number): Promise<void>;
38
+ /**
39
+ * Resolves once a backpressured write has drained. There is no deadline, as
40
+ * in Cursor CLI's transport: a stream error or close (remote or local) settles
41
+ * it, and the read-idle watchdog bounds a stream that stops answering.
42
+ */
43
+ waitForDrain?(): Promise<void>;
39
44
  end(): void;
40
45
  frames(): AsyncIterable<{
41
46
  flags: number;
@@ -73,9 +78,13 @@ export declare function resolveAgentOrigin(baseURL: string): string;
73
78
  export declare function closeCachedHttp2SessionsForTests(): void;
74
79
  /** Node-runtime regression hook; production callers use getSession(). */
75
80
  export declare function installSessionInvalidationForTests(origin: string, session: http2.ClientHttp2Session): void;
81
+ /** Node-runtime regression hook: cache a connected local session for `origin`. */
82
+ export declare function cacheHttp2SessionForTests(origin: string, session: http2.ClientHttp2Session): void;
76
83
  export declare function getSession(baseURL: string, options?: {
77
84
  pingTimeoutMs?: number;
78
85
  }): Promise<http2.ClientHttp2Session>;
86
+ export declare function frameArrival(frame: object): number | undefined;
87
+ export declare function lastFrameArrival(): number;
79
88
  export declare function bidiRunStream(token: string, options: {
80
89
  signal?: AbortSignal;
81
90
  baseURL: string;
@@ -194,6 +194,7 @@ export class CursorRunInterruptedError extends CursorTransportError {
194
194
  transient: true,
195
195
  replaySafe: true,
196
196
  cause: options?.cause,
197
+ code: options?.cause === undefined ? "CURSOR_STREAM_CLOSED_EARLY" : undefined,
197
198
  });
198
199
  this.name = "CursorRunInterruptedError";
199
200
  }
@@ -234,6 +235,12 @@ function cursorRunGrpcError(grpcStatus, headers, trailers) {
234
235
  const _http2Sessions = new Map();
235
236
  const _http2SessionCreatedAt = new WeakMap();
236
237
  const _http2SessionListenerCleanup = new WeakMap();
238
+ // As in Cursor CLI's transport (@connectrpc/connect-node 1.6.1
239
+ // Http2SessionManager), a cached session keeps the process alive only while a
240
+ // Run stream or a reuse ping is live on it: unref'd once connected, ref'd while
241
+ // it holds one. A Run held open on a pending exec keeps its stream, so it stays
242
+ // ref'd; an idle session would otherwise stop a headless host from exiting.
243
+ const _http2SessionHolds = new WeakMap();
237
244
  // Validation and connect work for the same origin shares one Promise.
238
245
  const _http2Connecting = new Map();
239
246
  // Bound the time we'll wait for the initial connect. Without this, a dead or
@@ -242,8 +249,16 @@ const _http2Connecting = new Map();
242
249
  const CONNECT_TIMEOUT_MS = 15_000;
243
250
  const DEFAULT_SESSION_PING_TIMEOUT_MS = 5_000;
244
251
  const DEFAULT_READ_IDLE_MS = 120_000;
245
- const WRITE_DRAIN_TIMEOUT_CODE = "CURSOR_WRITE_DRAIN_TIMEOUT";
246
252
  export const HTTP2_SESSION_MAX_AGE_MS = 15 * 60_000;
253
+ // Every Run shares one session per origin. Node and Bun count its unsent bytes
254
+ // against maxSessionMemory (10 MB by default) and, once over it, fail every new
255
+ // stream with ENHANCE_YOUR_CALM (Bun before sending it, Node by refusing its
256
+ // response headers); Node also fails a single write over it while the uplink
257
+ // holds the bytes back. Run requests and KV replies run to many megabytes, and
258
+ // the provider holds those bytes anyway, so the limit only adds a failure.
259
+ // 256 MB clears the 100 MiB checkpoint transfer budget with room to spare.
260
+ // Cursor CLI's transport sets no maxSessionMemory, so it keeps the default (#76).
261
+ const RUN_SESSION_OPTIONS = { maxSessionMemory: 256 };
247
262
  export function shouldReuseHttp2Session(state, createdAt, now = Date.now()) {
248
263
  return !state.destroyed && !state.closed && now - createdAt < HTTP2_SESSION_MAX_AGE_MS;
249
264
  }
@@ -259,6 +274,42 @@ function dropSession(origin, session) {
259
274
  if (_http2Sessions.get(origin) === session)
260
275
  _http2Sessions.delete(origin);
261
276
  }
277
+ /**
278
+ * Stop offering `session` for new Runs and close it so in-flight streams can
279
+ * drain. Used by age rotation and a failed reuse ping — never
280
+ * `destroy()`, which would abort every sibling Run multiplexed on the session
281
+ * mid-output (those Runs cannot be replayed after visible activity).
282
+ */
283
+ function retireCachedSession(origin, session) {
284
+ dropSession(origin, session);
285
+ try {
286
+ session.close();
287
+ }
288
+ catch { /* already closed */ }
289
+ }
290
+ function holdHttp2Session(session) {
291
+ const holds = _http2SessionHolds.get(session) ?? 0;
292
+ _http2SessionHolds.set(session, holds + 1);
293
+ if (holds === 0)
294
+ session.ref();
295
+ }
296
+ function releaseHttp2Session(session) {
297
+ const holds = (_http2SessionHolds.get(session) ?? 0) - 1;
298
+ if (holds > 0) {
299
+ _http2SessionHolds.set(session, holds);
300
+ return;
301
+ }
302
+ _http2SessionHolds.delete(session);
303
+ if (!session.destroyed)
304
+ session.unref();
305
+ }
306
+ /** Offer a connected session for new Runs; it stays unref'd until one holds it. */
307
+ function cacheReadySession(origin, session) {
308
+ if (!_http2SessionHolds.has(session))
309
+ session.unref();
310
+ _http2SessionCreatedAt.set(session, Date.now());
311
+ _http2Sessions.set(origin, session);
312
+ }
262
313
  /** Test cleanup for local HTTP/2 fixtures; production sessions stay process-cached. */
263
314
  export function closeCachedHttp2SessionsForTests() {
264
315
  for (const session of _http2Sessions.values()) {
@@ -354,6 +405,13 @@ function installSessionInvalidation(origin, session) {
354
405
  export function installSessionInvalidationForTests(origin, session) {
355
406
  installSessionInvalidation(origin, session);
356
407
  }
408
+ /** Node-runtime regression hook: cache a connected local session for `origin`. */
409
+ export function cacheHttp2SessionForTests(origin, session) {
410
+ // Tests re-cache one client per turn; replace its listeners rather than stack them.
411
+ _http2SessionListenerCleanup.get(session)?.();
412
+ installSessionInvalidation(origin, session);
413
+ cacheReadySession(origin, session);
414
+ }
357
415
  function invalidateSession(origin, session) {
358
416
  dropSession(origin, session);
359
417
  _http2SessionListenerCleanup.get(session)?.();
@@ -467,8 +525,7 @@ function connectSession(origin) {
467
525
  }
468
526
  // The TLS socket (if any) is owned by the HTTP/2 session from here on.
469
527
  tunnelSocket = undefined;
470
- _http2SessionCreatedAt.set(session, Date.now());
471
- _http2Sessions.set(origin, session);
528
+ cacheReadySession(origin, session);
472
529
  trace(`h2 session connected: origin=${origin}`);
473
530
  resolve(session);
474
531
  };
@@ -499,11 +556,12 @@ function connectSession(origin) {
499
556
  }
500
557
  tunnelSocket = tlsSocket;
501
558
  session = http2.connect(origin, {
559
+ ...RUN_SESSION_OPTIONS,
502
560
  createConnection: () => tlsSocket,
503
561
  });
504
562
  }
505
563
  else {
506
- session = http2.connect(origin);
564
+ session = http2.connect(origin, RUN_SESSION_OPTIONS);
507
565
  }
508
566
  if (settled) {
509
567
  try {
@@ -535,23 +593,27 @@ export function getSession(baseURL, options = {}) {
535
593
  const createdAt = _http2SessionCreatedAt.get(existing) ?? 0;
536
594
  if (!shouldReuseHttp2Session(existing, createdAt)) {
537
595
  trace(`h2 session rotate: origin=${origin} ageMs=${Math.max(0, Date.now() - createdAt)}`);
538
- dropSession(origin, existing);
539
- try {
540
- existing.close();
541
- }
542
- catch { /* already closed */ }
596
+ retireCachedSession(origin, existing);
543
597
  }
544
598
  else {
545
599
  try {
546
- await validateCachedSession(existing, pingTimeoutMs);
600
+ holdHttp2Session(existing);
601
+ try {
602
+ await validateCachedSession(existing, pingTimeoutMs);
603
+ }
604
+ finally {
605
+ releaseHttp2Session(existing);
606
+ }
547
607
  if (_http2Sessions.get(origin) === existing && !existing.destroyed && !existing.closed) {
548
608
  trace(`h2 cached session ping ok: origin=${origin}`);
549
609
  return existing;
550
610
  }
551
611
  }
552
612
  catch (error) {
613
+ // Sibling Runs may still be streaming on this session; destroy() would
614
+ // abort them mid-output, where they cannot be replayed.
553
615
  trace(`h2 cached session ping failed: origin=${origin} err=${error.message}`);
554
- invalidateSession(origin, existing);
616
+ retireCachedSession(origin, existing);
555
617
  }
556
618
  }
557
619
  }
@@ -565,6 +627,15 @@ export function getSession(baseURL, options = {}) {
565
627
  promise.then(cleanup, cleanup);
566
628
  return promise;
567
629
  }
630
+ /** Process-wide order in which Run frames came off the network, before any reader takes them. */
631
+ const frameArrivals = new WeakMap();
632
+ let lastArrival = 0;
633
+ export function frameArrival(frame) {
634
+ return frameArrivals.get(frame);
635
+ }
636
+ export function lastFrameArrival() {
637
+ return lastArrival;
638
+ }
568
639
  export async function bidiRunStream(token, options) {
569
640
  const origin = resolveAgentOrigin(options.baseURL);
570
641
  const readIdleMs = timeoutMs("Cursor provider readIdleMs", options.readIdleMs, DEFAULT_READ_IDLE_MS);
@@ -587,6 +658,7 @@ export async function bidiRunStream(token, options) {
587
658
  const stream = session.request(headers, {
588
659
  endStream: false,
589
660
  });
661
+ holdHttp2Session(session);
590
662
  let writable = true;
591
663
  let locallyClosed = false;
592
664
  let remotelyClosed = false;
@@ -626,6 +698,14 @@ export async function bidiRunStream(token, options) {
626
698
  }
627
699
  return undefined;
628
700
  };
701
+ const drainTerminalError = (event) => {
702
+ if (event.kind === "local-close") {
703
+ return new CursorLocalCancellationError("Cursor Run stream closed locally during drain");
704
+ }
705
+ if (event.kind === "remote-error")
706
+ return event.error;
707
+ return observedFailure() ?? new CursorRunInterruptedError("Cursor Run stream ended before drain");
708
+ };
629
709
  const settleTerminal = (event) => {
630
710
  if (terminalEvent)
631
711
  return;
@@ -751,8 +831,10 @@ export async function bidiRunStream(token, options) {
751
831
  pendingChunks.length = 0;
752
832
  if (consumed < merged.length)
753
833
  pendingChunks.push(merged.subarray(consumed));
754
- for (const frame of parsed)
834
+ for (const frame of parsed) {
835
+ frameArrivals.set(frame, ++lastArrival);
755
836
  enqueueFrame(frame);
837
+ }
756
838
  };
757
839
  const startInboundReader = () => {
758
840
  if (inboundReaderStarted || locallyClosed || remotelyClosed || streamFailure)
@@ -795,6 +877,7 @@ export async function bidiRunStream(token, options) {
795
877
  });
796
878
  stream.on("close", () => {
797
879
  writable = false;
880
+ releaseHttp2Session(session);
798
881
  remotelyClosed = !locallyClosed;
799
882
  if (remotelyClosed) {
800
883
  dropSession(origin, session);
@@ -810,6 +893,16 @@ export async function bidiRunStream(token, options) {
810
893
  `err=${rawStreamError instanceof Error ? rawStreamError.message : "none"})`);
811
894
  scheduleTerminalSettlement();
812
895
  });
896
+ // Reset with CANCEL, as Cursor CLI's transport does for an aborted call
897
+ // (@connectrpc/connect-node 1.6.1). A plain close() leaves a stream whose
898
+ // request write is still buffered open for good, which also keeps its
899
+ // session held and the process alive.
900
+ const closeLocally = () => {
901
+ try {
902
+ stream.close(http2.constants.NGHTTP2_CANCEL);
903
+ }
904
+ catch { /* already closing */ }
905
+ };
813
906
  if (abortSignal) {
814
907
  abortHandler = () => {
815
908
  if (!locallyClosed) {
@@ -820,7 +913,7 @@ export async function bidiRunStream(token, options) {
820
913
  finishInbound();
821
914
  stopInboundReader();
822
915
  settleTerminal({ kind: "local-close" });
823
- stream.close();
916
+ closeLocally();
824
917
  }
825
918
  };
826
919
  abortSignal.addEventListener("abort", abortHandler, { once: true });
@@ -851,20 +944,21 @@ export async function bidiRunStream(token, options) {
851
944
  throw streamFailure;
852
945
  }
853
946
  },
854
- waitForDrain(timeout) {
947
+ waitForDrain() {
855
948
  if (!backpressured)
856
949
  return Promise.resolve();
857
- const drainTimeoutMs = timeoutMs("Cursor write drain timeout", timeout, timeout);
950
+ if (terminalEvent)
951
+ return Promise.reject(drainTerminalError(terminalEvent));
858
952
  return new Promise((resolve, reject) => {
859
953
  let settled = false;
860
954
  const finish = (error) => {
861
955
  if (settled)
862
956
  return;
863
957
  settled = true;
864
- clearTimeout(timer);
865
958
  stream.removeListener("drain", onDrain);
866
959
  stream.removeListener("error", onError);
867
960
  stream.removeListener("close", onClose);
961
+ terminalListeners.delete(onTerminal);
868
962
  if (error)
869
963
  reject(error);
870
964
  else
@@ -884,18 +978,14 @@ export async function bidiRunStream(token, options) {
884
978
  ? new CursorLocalCancellationError("Cursor Run stream closed locally during drain")
885
979
  : observedFailure() ?? new CursorRunInterruptedError("Cursor Run stream closed before drain"));
886
980
  };
887
- const timer = setTimeout(() => {
888
- streamFailure ??= new CursorTransportError(`Cursor Run stream backpressure did not drain after ${drainTimeoutMs}ms`, { transient: false, replaySafe: false, code: WRITE_DRAIN_TIMEOUT_CODE });
889
- finish(streamFailure);
890
- try {
891
- stream.destroy(streamFailure);
892
- }
893
- catch { /* already closing */ }
894
- }, drainTimeoutMs);
895
- timer.unref?.();
981
+ // The Run's end settles it too: Node holds back `close` while a write
982
+ // is buffered, and a response can end while the request is still
983
+ // blocked, after which the read-idle watchdog no longer runs.
984
+ const onTerminal = (event) => finish(drainTerminalError(event));
896
985
  stream.once("drain", onDrain);
897
986
  stream.once("error", onError);
898
987
  stream.once("close", onClose);
988
+ terminalListeners.add(onTerminal);
899
989
  });
900
990
  },
901
991
  end() {
@@ -906,7 +996,12 @@ export async function bidiRunStream(token, options) {
906
996
  finishInbound();
907
997
  stopInboundReader();
908
998
  settleTerminal({ kind: "local-close" });
909
- stream.end();
999
+ // A blocked write cannot finish gracefully; discard it rather than leave
1000
+ // its stream and session referenced after the drain has been rejected.
1001
+ if (backpressured)
1002
+ closeLocally();
1003
+ else
1004
+ stream.end();
910
1005
  },
911
1006
  async *frames() {
912
1007
  try {
@@ -948,8 +1043,10 @@ export async function bidiRunStream(token, options) {
948
1043
  finishInbound();
949
1044
  stopInboundReader();
950
1045
  settleTerminal({ kind: "local-close" });
951
- stream.close();
952
1046
  }
1047
+ // end() may already have half-closed the request. destroy() must still
1048
+ // reset the stream if the peer has left its response open.
1049
+ closeLocally();
953
1050
  },
954
1051
  isClosed() {
955
1052
  return remotelyClosed || locallyClosed || stream.closed || stream.destroyed;
package/dist/usage.d.ts CHANGED
@@ -19,6 +19,8 @@ export type CursorCacheDiagnosticStats = {
19
19
  conversationGroupId?: string;
20
20
  modelId?: string;
21
21
  startedWithCheckpoint: boolean;
22
+ /** Why a Run started without a checkpoint: a reset reason, `ephemeral`, or `no-checkpoint`. */
23
+ coldReason?: string;
22
24
  requestContextReused: boolean;
23
25
  requestContextHash: string;
24
26
  systemPromptHash?: string;
@@ -51,31 +53,49 @@ export declare function formatCursorTokenCategories(details: CursorConversationT
51
53
  * does not expose per-model-call cache accounting on the Run stream.
52
54
  */
53
55
  export declare function formatCursorCacheDiagnostics(counters: CursorUsageCounters, current: CursorConversationTokenDetails | undefined, prior: CursorConversationTokenDetails | undefined, stats: CursorCacheDiagnosticStats): string;
54
- /** One-line proof that Cursor, AI SDK, and projected OpenCode totals agree. */
55
- export declare function formatTurnUsageValidation(counters: CursorUsageCounters, usage: LanguageModelV3Usage, tokenDetails?: CursorConversationTokenDetails, contextSource?: CursorContextUsageSource): string;
56
- /** OpenCode requires a usage object at every step boundary. */
57
- export declare function emptyLanguageModelV3Usage(): LanguageModelV3Usage;
58
56
  /**
59
- * OpenCode TUI/GUI replace each assistant message's `tokens` (they do not sum
60
- * occupancy). The TUI footer picks the last assistant with `tokens.output > 0`.
61
- * Session cost, however, adds every step-finish. Checkpoint occupancy is
62
- * therefore sent as a snapshot with `output=1` so the footer accepts it, and
63
- * callers attach {@link OPENCODE_DISPLAY_ONLY_COST_METADATA} so getUsage
64
- * reports $0 instead of billing the snapshot as a new prompt.
57
+ * One-line proof that Cursor, AI SDK, and projected OpenCode totals agree.
58
+ * `counterKind` names what `counters` hold so the line labels them truthfully:
59
+ * Cursor's TurnEnded request counters (`raw*`) or the occupancy-shaped counters
60
+ * of {@link occupancyValidationCounters} (`occupancy*`).
65
61
  */
66
- export declare const OPENCODE_DISPLAY_ONLY_COST_METADATA: {
67
- readonly copilot: {
68
- readonly totalNanoAiu: 0;
62
+ export declare function formatTurnUsageValidation(counters: CursorUsageCounters, usage: LanguageModelV3Usage, tokenDetails?: CursorConversationTokenDetails, contextSource?: CursorContextUsageSource, counterKind?: "turn-ended" | "occupancy"): string;
63
+ /**
64
+ * The `finish:` trace. `v3*` is the usage sent to OpenCode. The second group is
65
+ * labelled by where its numbers come from: `raw*` are Cursor's TurnEnded
66
+ * request counters (whole Run), `occupancy*` the checkpoint snapshot a
67
+ * tool-call boundary sends (its cache split as billed: the previous step's
68
+ * context, see `src/billing.ts`), and
69
+ * `est*` the provider's char/4 estimate before any checkpoint arrived.
70
+ */
71
+ export declare function formatFinishTrace(input: {
72
+ reason: string;
73
+ usage: LanguageModelV3Usage;
74
+ turnEnded?: CursorUsageCounters;
75
+ occupancy?: {
76
+ usedTokens: number;
77
+ priorUsedTokens: number;
69
78
  };
70
- };
79
+ estimate: CursorUsageCounters;
80
+ source: string;
81
+ }): string;
82
+ /**
83
+ * A warning when Cursor's checkpoint counts no rules although this Run sent the
84
+ * system-instructions rule: the host system context (AGENTS.md, skills,
85
+ * subagents) is then likely absent from the model's prompt. Undefined when the
86
+ * breakdown is missing or stale, or when rules were counted.
87
+ */
88
+ export declare function missingRulesWarning(details: CursorConversationTokenDetails | undefined, sentRuleChars: number, conversationId: string): string | undefined;
89
+ /** OpenCode requires a usage object at every step boundary. */
90
+ export declare function emptyLanguageModelV3Usage(): LanguageModelV3Usage;
71
91
  /**
72
- * Counters that mirror {@link occupancyUsageFromTokenDetails} for
73
- * {@link formatTurnUsageValidation}. Always validate occupancy finishes —
92
+ * Occupancy-shaped counters for {@link formatTurnUsageValidation}: `usedTokens`
93
+ * with `prior.usedTokens` of it cached, the shape every step finish sends
94
+ * (`shapeStepUsage` in `src/billing.ts`). Always validate occupancy finishes —
74
95
  * including TurnEnded/stop — against these, not against aggregate TurnEnded
75
96
  * request counters. Request cache ratios stay on `finish:` / cache diagnosis.
76
97
  */
77
98
  export declare function occupancyValidationCounters(details: CursorConversationTokenDetails, prior?: CursorConversationTokenDetails): CursorUsageCounters;
78
- export declare function occupancyUsageFromTokenDetails(details: CursorConversationTokenDetails, prior?: CursorConversationTokenDetails): LanguageModelV3Usage;
79
99
  /** Project nested V3 usage into the common flat AI-SDK counter shape. */
80
100
  export declare function flatUsageFromV3(usage: LanguageModelV3Usage): {
81
101
  inputTokens: number;
package/dist/usage.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { currentCursorTokenBreakdown } from "./protocol/token-details.js";
1
2
  /** Non-negative integer counter from a Cursor `turn_ended` field. */
2
3
  export function turnEndedCounter(te, key) {
3
4
  const value = te[key];
@@ -75,7 +76,7 @@ function usageRatio(part, total) {
75
76
  return total > 0 ? `${(part / total * 100).toFixed(1)}%` : "n/a";
76
77
  }
77
78
  function categoryTokens(details) {
78
- return new Map(details?.breakdown?.categories.map((category) => [
79
+ return new Map(currentCursorTokenBreakdown(details)?.categories.map((category) => [
79
80
  category.id || category.label || "(unnamed)",
80
81
  category.estimatedTokens,
81
82
  ]) ?? []);
@@ -97,7 +98,7 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
97
98
  const rawUncached = Math.max(0, rawInput - rawRead - rawWrite);
98
99
  const priorCategories = categoryTokens(prior);
99
100
  const currentCategories = categoryTokens(current);
100
- const categoriesComparable = !!prior?.breakdown && !!current?.breakdown;
101
+ const categoriesComparable = !!currentCursorTokenBreakdown(prior) && !!currentCursorTokenBreakdown(current);
101
102
  const categoryDelta = {};
102
103
  let sameSizedCategoryTokens = 0;
103
104
  if (categoriesComparable) {
@@ -132,6 +133,13 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
132
133
  && toolsDelta !== 0
133
134
  ? "client-overlay-changed"
134
135
  : "none";
136
+ // Occupancy measures context size, not cache reuse. A zero counter on an
137
+ // interaction turn cannot distinguish omitted accounting from a cache miss.
138
+ const interactionZeroRead = stats.startedWithCheckpoint
139
+ && rawRead === 0
140
+ && typeof prior?.usedTokens === "number"
141
+ && prior.usedTokens > 0
142
+ && (stats.createPlanInTurn === true || stats.switchModeInTurn === true);
135
143
  return [
136
144
  "cache diagnosis:",
137
145
  `sessionKey=${stats.sessionKey ?? "-"}`,
@@ -139,6 +147,7 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
139
147
  `conversationGroupId=${stats.conversationGroupId ?? "-"}`,
140
148
  `model=${stats.modelId ?? "-"}`,
141
149
  `continuity=${continuity}`,
150
+ `coldReason=${continuity === "cold" ? stats.coldReason ?? "unknown" : "-"}`,
142
151
  `rawInput=${rawInput}`,
143
152
  `rawCacheRead=${rawRead}`,
144
153
  `rawCacheWrite=${rawWrite}`,
@@ -148,7 +157,9 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
148
157
  `priorContext=${prior?.usedTokens ?? "unavailable"}`,
149
158
  `currentContext=${current?.usedTokens ?? "unavailable"}`,
150
159
  `contextDelta=${contextDelta ?? "unavailable"}`,
151
- `rawReadVsPriorContext=${prior ? usageRatio(rawRead, prior.usedTokens) : "n/a"}`,
160
+ // Several model calls in one Run each re-read the prefix, so this multiple
161
+ // routinely exceeds 1 and is not a percentage.
162
+ `rawReadVsPriorContext=${prior && prior.usedTokens > 0 ? `${(rawRead / prior.usedTokens).toFixed(2)}x` : "n/a"}`,
152
163
  `sameSizedCategoryTokens=${categoriesComparable ? sameSizedCategoryTokens : "unavailable"}`,
153
164
  `categoryDelta=${categoriesComparable && Object.keys(categoryDelta).length > 0 ? JSON.stringify(categoryDelta) : "unavailable"}`,
154
165
  `toolsCategoryChurn=${toolsCategoryChurn}`,
@@ -164,11 +175,23 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
164
175
  `execRequests=${stats.execRequests}`,
165
176
  `createPlanInTurn=${stats.createPlanInTurn === true}`,
166
177
  `switchModeInTurn=${stats.switchModeInTurn === true}`,
178
+ ...(interactionZeroRead
179
+ ? [
180
+ "turnEndedCacheRead=zero-interaction",
181
+ "cacheReuseEvidence=unavailable",
182
+ ]
183
+ : []),
167
184
  "perModelCallCache=unavailable",
168
185
  ].join(" ");
169
186
  }
170
- /** One-line proof that Cursor, AI SDK, and projected OpenCode totals agree. */
171
- export function formatTurnUsageValidation(counters, usage, tokenDetails, contextSource) {
187
+ /**
188
+ * One-line proof that Cursor, AI SDK, and projected OpenCode totals agree.
189
+ * `counterKind` names what `counters` hold so the line labels them truthfully:
190
+ * Cursor's TurnEnded request counters (`raw*`) or the occupancy-shaped counters
191
+ * of {@link occupancyValidationCounters} (`occupancy*`).
192
+ */
193
+ export function formatTurnUsageValidation(counters, usage, tokenDetails, contextSource, counterKind = "turn-ended") {
194
+ const counterLabel = counterKind === "occupancy" ? "occupancy" : "raw";
172
195
  const input = usageCount(usage.inputTokens.total);
173
196
  const noCache = usageCount(usage.inputTokens.noCache);
174
197
  const cacheRead = usageCount(usage.inputTokens.cacheRead);
@@ -189,8 +212,9 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
189
212
  const breakdown = tokenDetails?.breakdown;
190
213
  const categorySum = breakdown?.categories.reduce((sum, category) => sum + category.estimatedTokens, 0);
191
214
  const breakdownMatch = breakdown && categorySum !== undefined
192
- ? breakdown.totalUsedTokens === tokenDetails.usedTokens &&
193
- categorySum === breakdown.totalUsedTokens
215
+ ? categorySum !== breakdown.totalUsedTokens
216
+ ? false
217
+ : currentCursorTokenBreakdown(tokenDetails) ? true : "stale"
194
218
  : undefined;
195
219
  const rawCached = counters.cacheRead + counters.cacheWrite;
196
220
  const sentCached = cacheRead + cacheWrite;
@@ -206,9 +230,10 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
206
230
  const status = input === inputParts &&
207
231
  output === outputParts &&
208
232
  projectedOpenCodeTotal === sentTotal &&
233
+ (counterKind !== "occupancy" || rawTotal === sentTotal) &&
209
234
  (cacheRatioMatch ?? true) &&
210
235
  (!tokenDetails || sentTotal === tokenDetails.usedTokens) &&
211
- (breakdownMatch ?? true)
236
+ breakdownMatch !== false
212
237
  ? "ok"
213
238
  : "mismatch";
214
239
  return [
@@ -216,7 +241,7 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
216
241
  `status=${status}`,
217
242
  `source=${tokenDetails ? contextSource ?? "checkpoint-current-run" : "unavailable"}`,
218
243
  `cursor=${cursor}`,
219
- `rawTotal=${rawTotal}`,
244
+ `${counterLabel}Total=${rawTotal}`,
220
245
  `sentTotal=${sentTotal}`,
221
246
  `totalMatch=${totalMatch}`,
222
247
  `input=${input}`,
@@ -230,11 +255,53 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
230
255
  `breakdownTotal=${breakdown?.totalUsedTokens ?? "unavailable"}`,
231
256
  `categorySum=${categorySum ?? "unavailable"}`,
232
257
  `breakdownMatch=${breakdownMatch ?? "unavailable"}`,
233
- `rawCachedRatio=${usageRatio(rawCached, counters.inputTokens)}`,
258
+ `${counterLabel}CachedRatio=${usageRatio(rawCached, counters.inputTokens)}`,
234
259
  `sentCachedRatio=${usageRatio(sentCached, input)}`,
235
260
  `cacheRatioMatch=${cacheRatioMatch ?? "unavailable"}`,
236
261
  ].join(" ");
237
262
  }
263
+ /**
264
+ * The `finish:` trace. `v3*` is the usage sent to OpenCode. The second group is
265
+ * labelled by where its numbers come from: `raw*` are Cursor's TurnEnded
266
+ * request counters (whole Run), `occupancy*` the checkpoint snapshot a
267
+ * tool-call boundary sends (its cache split as billed: the previous step's
268
+ * context, see `src/billing.ts`), and
269
+ * `est*` the provider's char/4 estimate before any checkpoint arrived.
270
+ */
271
+ export function formatFinishTrace(input) {
272
+ const { usage, turnEnded, occupancy, estimate } = input;
273
+ const counters = turnEnded
274
+ ? `rawIn=${turnEnded.inputTokens} rawOut=${turnEnded.outputTokens} ` +
275
+ `rawCacheRead=${turnEnded.cacheRead} rawCacheWrite=${turnEnded.cacheWrite} ` +
276
+ (occupancy ? `occupancyPrefixCache=${occupancy.priorUsedTokens} ` : "")
277
+ : occupancy
278
+ ? `occupancyIn=${Math.max(0, occupancy.usedTokens - 1)} occupancyOut=${occupancy.usedTokens > 0 ? 1 : 0} ` +
279
+ `occupancyCacheRead=${usage.inputTokens?.cacheRead ?? 0} ` +
280
+ `occupancyCacheWrite=${usage.inputTokens?.cacheWrite ?? 0} `
281
+ : `estIn=${estimate.inputTokens} estOut=${estimate.outputTokens} ` +
282
+ `estCacheRead=${estimate.cacheRead} estCacheWrite=${estimate.cacheWrite} `;
283
+ return `finish: reason=${input.reason} ` +
284
+ `v3In=${usage.inputTokens?.total ?? 0} v3Out=${usage.outputTokens?.total ?? 0} ` +
285
+ `v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
286
+ `v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
287
+ counters +
288
+ `source=${input.source}`;
289
+ }
290
+ /**
291
+ * A warning when Cursor's checkpoint counts no rules although this Run sent the
292
+ * system-instructions rule: the host system context (AGENTS.md, skills,
293
+ * subagents) is then likely absent from the model's prompt. Undefined when the
294
+ * breakdown is missing or stale, or when rules were counted.
295
+ */
296
+ export function missingRulesWarning(details, sentRuleChars, conversationId) {
297
+ if (sentRuleChars <= 0)
298
+ return undefined;
299
+ const rules = currentCursorTokenBreakdown(details)?.categories.find((category) => category.id === "rules");
300
+ if (!rules || rules.estimatedTokens > 0)
301
+ return undefined;
302
+ return `context warning: Cursor counted rules=0 although this Run sent the ${sentRuleChars}-char ` +
303
+ `system-instructions rule conversationId=${conversationId} — host instructions may not reach the model`;
304
+ }
238
305
  /** OpenCode requires a usage object at every step boundary. */
239
306
  export function emptyLanguageModelV3Usage() {
240
307
  return buildLanguageModelV3UsageFromCounters({
@@ -246,41 +313,25 @@ export function emptyLanguageModelV3Usage() {
246
313
  });
247
314
  }
248
315
  /**
249
- * OpenCode TUI/GUI replace each assistant message's `tokens` (they do not sum
250
- * occupancy). The TUI footer picks the last assistant with `tokens.output > 0`.
251
- * Session cost, however, adds every step-finish. Checkpoint occupancy is
252
- * therefore sent as a snapshot with `output=1` so the footer accepts it, and
253
- * callers attach {@link OPENCODE_DISPLAY_ONLY_COST_METADATA} so getUsage
254
- * reports $0 instead of billing the snapshot as a new prompt.
255
- */
256
- export const OPENCODE_DISPLAY_ONLY_COST_METADATA = {
257
- copilot: { totalNanoAiu: 0 },
258
- };
259
- /**
260
- * Counters that mirror {@link occupancyUsageFromTokenDetails} for
261
- * {@link formatTurnUsageValidation}. Always validate occupancy finishes —
316
+ * Occupancy-shaped counters for {@link formatTurnUsageValidation}: `usedTokens`
317
+ * with `prior.usedTokens` of it cached, the shape every step finish sends
318
+ * (`shapeStepUsage` in `src/billing.ts`). Always validate occupancy finishes —
262
319
  * including TurnEnded/stop — against these, not against aggregate TurnEnded
263
320
  * request counters. Request cache ratios stay on `finish:` / cache diagnosis.
264
321
  */
265
322
  export function occupancyValidationCounters(details, prior) {
266
323
  const used = Math.max(0, Math.trunc(details.usedTokens));
324
+ const input = Math.max(0, used - 1);
267
325
  return {
268
- inputTokens: used,
326
+ inputTokens: input,
269
327
  outputTokens: used > 0 ? 1 : 0,
270
- cacheRead: Math.max(0, Math.trunc(prior?.usedTokens ?? 0)),
328
+ // Cursor can shrink the context between checkpoints; the prior prefix
329
+ // cannot be larger than what is in context now.
330
+ cacheRead: Math.min(input, Math.max(0, Math.trunc(prior?.usedTokens ?? 0))),
271
331
  cacheWrite: 0,
272
332
  reasoningTokens: 0,
273
333
  };
274
334
  }
275
- export function occupancyUsageFromTokenDetails(details, prior) {
276
- const used = Math.max(0, Math.trunc(details.usedTokens));
277
- if (used <= 0)
278
- return emptyLanguageModelV3Usage();
279
- return buildLanguageModelV3UsageFromCounters(occupancyValidationCounters(details, prior), {
280
- contextTotalTokens: used,
281
- priorContextTokens: prior?.usedTokens,
282
- });
283
- }
284
335
  /** Project nested V3 usage into the common flat AI-SDK counter shape. */
285
336
  export function flatUsageFromV3(usage) {
286
337
  const input = usage.inputTokens;