cursor-opencode-provider 0.8.1 → 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 (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  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 +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
@@ -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;
@@ -78,6 +83,8 @@ export declare function cacheHttp2SessionForTests(origin: string, session: http2
78
83
  export declare function getSession(baseURL: string, options?: {
79
84
  pingTimeoutMs?: number;
80
85
  }): Promise<http2.ClientHttp2Session>;
86
+ export declare function frameArrival(frame: object): number | undefined;
87
+ export declare function lastFrameArrival(): number;
81
88
  export declare function bidiRunStream(token: string, options: {
82
89
  signal?: AbortSignal;
83
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
  }
@@ -272,6 +287,29 @@ function retireCachedSession(origin, session) {
272
287
  }
273
288
  catch { /* already closed */ }
274
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
+ }
275
313
  /** Test cleanup for local HTTP/2 fixtures; production sessions stay process-cached. */
276
314
  export function closeCachedHttp2SessionsForTests() {
277
315
  for (const session of _http2Sessions.values()) {
@@ -369,9 +407,10 @@ export function installSessionInvalidationForTests(origin, session) {
369
407
  }
370
408
  /** Node-runtime regression hook: cache a connected local session for `origin`. */
371
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)?.();
372
412
  installSessionInvalidation(origin, session);
373
- _http2SessionCreatedAt.set(session, Date.now());
374
- _http2Sessions.set(origin, session);
413
+ cacheReadySession(origin, session);
375
414
  }
376
415
  function invalidateSession(origin, session) {
377
416
  dropSession(origin, session);
@@ -486,8 +525,7 @@ function connectSession(origin) {
486
525
  }
487
526
  // The TLS socket (if any) is owned by the HTTP/2 session from here on.
488
527
  tunnelSocket = undefined;
489
- _http2SessionCreatedAt.set(session, Date.now());
490
- _http2Sessions.set(origin, session);
528
+ cacheReadySession(origin, session);
491
529
  trace(`h2 session connected: origin=${origin}`);
492
530
  resolve(session);
493
531
  };
@@ -518,11 +556,12 @@ function connectSession(origin) {
518
556
  }
519
557
  tunnelSocket = tlsSocket;
520
558
  session = http2.connect(origin, {
559
+ ...RUN_SESSION_OPTIONS,
521
560
  createConnection: () => tlsSocket,
522
561
  });
523
562
  }
524
563
  else {
525
- session = http2.connect(origin);
564
+ session = http2.connect(origin, RUN_SESSION_OPTIONS);
526
565
  }
527
566
  if (settled) {
528
567
  try {
@@ -558,7 +597,13 @@ export function getSession(baseURL, options = {}) {
558
597
  }
559
598
  else {
560
599
  try {
561
- await validateCachedSession(existing, pingTimeoutMs);
600
+ holdHttp2Session(existing);
601
+ try {
602
+ await validateCachedSession(existing, pingTimeoutMs);
603
+ }
604
+ finally {
605
+ releaseHttp2Session(existing);
606
+ }
562
607
  if (_http2Sessions.get(origin) === existing && !existing.destroyed && !existing.closed) {
563
608
  trace(`h2 cached session ping ok: origin=${origin}`);
564
609
  return existing;
@@ -582,6 +627,15 @@ export function getSession(baseURL, options = {}) {
582
627
  promise.then(cleanup, cleanup);
583
628
  return promise;
584
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
+ }
585
639
  export async function bidiRunStream(token, options) {
586
640
  const origin = resolveAgentOrigin(options.baseURL);
587
641
  const readIdleMs = timeoutMs("Cursor provider readIdleMs", options.readIdleMs, DEFAULT_READ_IDLE_MS);
@@ -604,6 +658,7 @@ export async function bidiRunStream(token, options) {
604
658
  const stream = session.request(headers, {
605
659
  endStream: false,
606
660
  });
661
+ holdHttp2Session(session);
607
662
  let writable = true;
608
663
  let locallyClosed = false;
609
664
  let remotelyClosed = false;
@@ -643,6 +698,14 @@ export async function bidiRunStream(token, options) {
643
698
  }
644
699
  return undefined;
645
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
+ };
646
709
  const settleTerminal = (event) => {
647
710
  if (terminalEvent)
648
711
  return;
@@ -768,8 +831,10 @@ export async function bidiRunStream(token, options) {
768
831
  pendingChunks.length = 0;
769
832
  if (consumed < merged.length)
770
833
  pendingChunks.push(merged.subarray(consumed));
771
- for (const frame of parsed)
834
+ for (const frame of parsed) {
835
+ frameArrivals.set(frame, ++lastArrival);
772
836
  enqueueFrame(frame);
837
+ }
773
838
  };
774
839
  const startInboundReader = () => {
775
840
  if (inboundReaderStarted || locallyClosed || remotelyClosed || streamFailure)
@@ -812,6 +877,7 @@ export async function bidiRunStream(token, options) {
812
877
  });
813
878
  stream.on("close", () => {
814
879
  writable = false;
880
+ releaseHttp2Session(session);
815
881
  remotelyClosed = !locallyClosed;
816
882
  if (remotelyClosed) {
817
883
  dropSession(origin, session);
@@ -827,6 +893,16 @@ export async function bidiRunStream(token, options) {
827
893
  `err=${rawStreamError instanceof Error ? rawStreamError.message : "none"})`);
828
894
  scheduleTerminalSettlement();
829
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
+ };
830
906
  if (abortSignal) {
831
907
  abortHandler = () => {
832
908
  if (!locallyClosed) {
@@ -837,7 +913,7 @@ export async function bidiRunStream(token, options) {
837
913
  finishInbound();
838
914
  stopInboundReader();
839
915
  settleTerminal({ kind: "local-close" });
840
- stream.close();
916
+ closeLocally();
841
917
  }
842
918
  };
843
919
  abortSignal.addEventListener("abort", abortHandler, { once: true });
@@ -868,20 +944,21 @@ export async function bidiRunStream(token, options) {
868
944
  throw streamFailure;
869
945
  }
870
946
  },
871
- waitForDrain(timeout) {
947
+ waitForDrain() {
872
948
  if (!backpressured)
873
949
  return Promise.resolve();
874
- const drainTimeoutMs = timeoutMs("Cursor write drain timeout", timeout, timeout);
950
+ if (terminalEvent)
951
+ return Promise.reject(drainTerminalError(terminalEvent));
875
952
  return new Promise((resolve, reject) => {
876
953
  let settled = false;
877
954
  const finish = (error) => {
878
955
  if (settled)
879
956
  return;
880
957
  settled = true;
881
- clearTimeout(timer);
882
958
  stream.removeListener("drain", onDrain);
883
959
  stream.removeListener("error", onError);
884
960
  stream.removeListener("close", onClose);
961
+ terminalListeners.delete(onTerminal);
885
962
  if (error)
886
963
  reject(error);
887
964
  else
@@ -901,18 +978,14 @@ export async function bidiRunStream(token, options) {
901
978
  ? new CursorLocalCancellationError("Cursor Run stream closed locally during drain")
902
979
  : observedFailure() ?? new CursorRunInterruptedError("Cursor Run stream closed before drain"));
903
980
  };
904
- const timer = setTimeout(() => {
905
- streamFailure ??= new CursorTransportError(`Cursor Run stream backpressure did not drain after ${drainTimeoutMs}ms`, { transient: false, replaySafe: false, code: WRITE_DRAIN_TIMEOUT_CODE });
906
- finish(streamFailure);
907
- try {
908
- stream.destroy(streamFailure);
909
- }
910
- catch { /* already closing */ }
911
- }, drainTimeoutMs);
912
- 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));
913
985
  stream.once("drain", onDrain);
914
986
  stream.once("error", onError);
915
987
  stream.once("close", onClose);
988
+ terminalListeners.add(onTerminal);
916
989
  });
917
990
  },
918
991
  end() {
@@ -923,7 +996,12 @@ export async function bidiRunStream(token, options) {
923
996
  finishInbound();
924
997
  stopInboundReader();
925
998
  settleTerminal({ kind: "local-close" });
926
- 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();
927
1005
  },
928
1006
  async *frames() {
929
1007
  try {
@@ -965,8 +1043,10 @@ export async function bidiRunStream(token, options) {
965
1043
  finishInbound();
966
1044
  stopInboundReader();
967
1045
  settleTerminal({ kind: "local-close" });
968
- stream.close();
969
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();
970
1050
  },
971
1051
  isClosed() {
972
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
@@ -147,6 +147,7 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
147
147
  `conversationGroupId=${stats.conversationGroupId ?? "-"}`,
148
148
  `model=${stats.modelId ?? "-"}`,
149
149
  `continuity=${continuity}`,
150
+ `coldReason=${continuity === "cold" ? stats.coldReason ?? "unknown" : "-"}`,
150
151
  `rawInput=${rawInput}`,
151
152
  `rawCacheRead=${rawRead}`,
152
153
  `rawCacheWrite=${rawWrite}`,
@@ -156,7 +157,9 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
156
157
  `priorContext=${prior?.usedTokens ?? "unavailable"}`,
157
158
  `currentContext=${current?.usedTokens ?? "unavailable"}`,
158
159
  `contextDelta=${contextDelta ?? "unavailable"}`,
159
- `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"}`,
160
163
  `sameSizedCategoryTokens=${categoriesComparable ? sameSizedCategoryTokens : "unavailable"}`,
161
164
  `categoryDelta=${categoriesComparable && Object.keys(categoryDelta).length > 0 ? JSON.stringify(categoryDelta) : "unavailable"}`,
162
165
  `toolsCategoryChurn=${toolsCategoryChurn}`,
@@ -181,8 +184,14 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
181
184
  "perModelCallCache=unavailable",
182
185
  ].join(" ");
183
186
  }
184
- /** One-line proof that Cursor, AI SDK, and projected OpenCode totals agree. */
185
- 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";
186
195
  const input = usageCount(usage.inputTokens.total);
187
196
  const noCache = usageCount(usage.inputTokens.noCache);
188
197
  const cacheRead = usageCount(usage.inputTokens.cacheRead);
@@ -221,6 +230,7 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
221
230
  const status = input === inputParts &&
222
231
  output === outputParts &&
223
232
  projectedOpenCodeTotal === sentTotal &&
233
+ (counterKind !== "occupancy" || rawTotal === sentTotal) &&
224
234
  (cacheRatioMatch ?? true) &&
225
235
  (!tokenDetails || sentTotal === tokenDetails.usedTokens) &&
226
236
  breakdownMatch !== false
@@ -231,7 +241,7 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
231
241
  `status=${status}`,
232
242
  `source=${tokenDetails ? contextSource ?? "checkpoint-current-run" : "unavailable"}`,
233
243
  `cursor=${cursor}`,
234
- `rawTotal=${rawTotal}`,
244
+ `${counterLabel}Total=${rawTotal}`,
235
245
  `sentTotal=${sentTotal}`,
236
246
  `totalMatch=${totalMatch}`,
237
247
  `input=${input}`,
@@ -245,11 +255,53 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
245
255
  `breakdownTotal=${breakdown?.totalUsedTokens ?? "unavailable"}`,
246
256
  `categorySum=${categorySum ?? "unavailable"}`,
247
257
  `breakdownMatch=${breakdownMatch ?? "unavailable"}`,
248
- `rawCachedRatio=${usageRatio(rawCached, counters.inputTokens)}`,
258
+ `${counterLabel}CachedRatio=${usageRatio(rawCached, counters.inputTokens)}`,
249
259
  `sentCachedRatio=${usageRatio(sentCached, input)}`,
250
260
  `cacheRatioMatch=${cacheRatioMatch ?? "unavailable"}`,
251
261
  ].join(" ");
252
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
+ }
253
305
  /** OpenCode requires a usage object at every step boundary. */
254
306
  export function emptyLanguageModelV3Usage() {
255
307
  return buildLanguageModelV3UsageFromCounters({
@@ -261,43 +313,25 @@ export function emptyLanguageModelV3Usage() {
261
313
  });
262
314
  }
263
315
  /**
264
- * OpenCode TUI/GUI replace each assistant message's `tokens` (they do not sum
265
- * occupancy). The TUI footer picks the last assistant with `tokens.output > 0`.
266
- * Session cost, however, adds every step-finish. Checkpoint occupancy is
267
- * therefore sent as a snapshot with `output=1` so the footer accepts it, and
268
- * callers attach {@link OPENCODE_DISPLAY_ONLY_COST_METADATA} so getUsage
269
- * reports $0 instead of billing the snapshot as a new prompt.
270
- */
271
- export const OPENCODE_DISPLAY_ONLY_COST_METADATA = {
272
- copilot: { totalNanoAiu: 0 },
273
- };
274
- /**
275
- * Counters that mirror {@link occupancyUsageFromTokenDetails} for
276
- * {@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 —
277
319
  * including TurnEnded/stop — against these, not against aggregate TurnEnded
278
320
  * request counters. Request cache ratios stay on `finish:` / cache diagnosis.
279
321
  */
280
322
  export function occupancyValidationCounters(details, prior) {
281
323
  const used = Math.max(0, Math.trunc(details.usedTokens));
324
+ const input = Math.max(0, used - 1);
282
325
  return {
283
- inputTokens: used,
326
+ inputTokens: input,
284
327
  outputTokens: used > 0 ? 1 : 0,
285
328
  // Cursor can shrink the context between checkpoints; the prior prefix
286
329
  // cannot be larger than what is in context now.
287
- cacheRead: Math.min(used, Math.max(0, Math.trunc(prior?.usedTokens ?? 0))),
330
+ cacheRead: Math.min(input, Math.max(0, Math.trunc(prior?.usedTokens ?? 0))),
288
331
  cacheWrite: 0,
289
332
  reasoningTokens: 0,
290
333
  };
291
334
  }
292
- export function occupancyUsageFromTokenDetails(details, prior) {
293
- const used = Math.max(0, Math.trunc(details.usedTokens));
294
- if (used <= 0)
295
- return emptyLanguageModelV3Usage();
296
- return buildLanguageModelV3UsageFromCounters(occupancyValidationCounters(details, prior), {
297
- contextTotalTokens: used,
298
- priorContextTokens: prior?.usedTokens,
299
- });
300
- }
301
335
  /** Project nested V3 usage into the common flat AI-SDK counter shape. */
302
336
  export function flatUsageFromV3(usage) {
303
337
  const input = usage.inputTokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -77,7 +77,8 @@
77
77
  "build": "tsc",
78
78
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
79
79
  "test": "bun test",
80
- "test:node-http2": "bun run build && node test/node-http2-cleanup.mjs",
80
+ "test:coverage": "bun test --coverage --coverage-reporter=lcov && bun scripts/strip-copied-coverage.ts",
81
+ "test:node-http2": "bun run build && node test/node-http2-cleanup.mjs && node test/node-http2-drain.mjs && node test/node-http2-session-memory.mjs",
81
82
  "test:watch": "bun test --watch",
82
83
  "generate:pricing": "bun run scripts/generate-cursor-pricing.ts",
83
84
  "generate:pricing:check": "bun run scripts/generate-cursor-pricing.ts -- --check",
@@ -1,15 +0,0 @@
1
- export type EffortConfig = {
2
- reasoningEffort?: string;
3
- maxMode?: boolean;
4
- };
5
- /**
6
- * Build RequestedModel parameter list from a model's variant parameters
7
- * and optional overrides from providerOptions.
8
- */
9
- export declare function buildRequestedModelParams(variantParameters: Array<{
10
- id: string;
11
- value: string;
12
- }>, options?: EffortConfig): Array<{
13
- id: string;
14
- value: string;
15
- }>;
@@ -1,17 +0,0 @@
1
- // Reasoning effort / max mode → RequestedModel parameters
2
- /**
3
- * Build RequestedModel parameter list from a model's variant parameters
4
- * and optional overrides from providerOptions.
5
- */
6
- export function buildRequestedModelParams(variantParameters, options) {
7
- // Start with variant's parameters as base
8
- const params = variantParameters.map((p) => ({ ...p }));
9
- // Override effort if specified
10
- if (options?.reasoningEffort) {
11
- const effortIdx = params.findIndex((p) => p.id === "effort" || p.id === "reasoning");
12
- if (effortIdx >= 0) {
13
- params[effortIdx] = { ...params[effortIdx], value: options.reasoningEffort };
14
- }
15
- }
16
- return params;
17
- }