cursor-opencode-provider 0.8.0 → 0.8.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 (67) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +28 -9
  3. package/SECURITY.md +43 -7
  4. package/dist/context/build.d.ts +22 -4
  5. package/dist/context/build.js +33 -5
  6. package/dist/context/dynamic-catalog.js +6 -1
  7. package/dist/context/frozen.js +6 -2
  8. package/dist/context/index.d.ts +2 -0
  9. package/dist/context/index.js +2 -0
  10. package/dist/context/paths.d.ts +44 -17
  11. package/dist/context/paths.js +65 -18
  12. package/dist/context/skills-bridge.d.ts +22 -0
  13. package/dist/context/skills-bridge.js +20 -0
  14. package/dist/context/skills.d.ts +34 -0
  15. package/dist/context/skills.js +211 -0
  16. package/dist/host-agent-mode.d.ts +53 -4
  17. package/dist/host-agent-mode.js +117 -13
  18. package/dist/host-plan-file.d.ts +28 -0
  19. package/dist/host-plan-file.js +76 -0
  20. package/dist/image-input.d.ts +18 -0
  21. package/dist/image-input.js +119 -28
  22. package/dist/image-save.d.ts +10 -2
  23. package/dist/image-save.js +33 -21
  24. package/dist/language-model.d.ts +45 -17
  25. package/dist/language-model.js +925 -292
  26. package/dist/model-config.d.ts +9 -0
  27. package/dist/model-config.js +28 -1
  28. package/dist/opencode2/catalog.d.ts +1 -0
  29. package/dist/opencode2/catalog.js +2 -0
  30. package/dist/opencode2/image-save-tool.d.ts +8 -0
  31. package/dist/opencode2/image-save-tool.js +84 -0
  32. package/dist/opencode2/types.d.ts +15 -0
  33. package/dist/parallel-step.d.ts +65 -0
  34. package/dist/parallel-step.js +102 -0
  35. package/dist/plugin-opencode2.js +48 -40
  36. package/dist/plugin.js +69 -18
  37. package/dist/pricing-data.d.ts +14 -1
  38. package/dist/pricing-data.js +14 -1
  39. package/dist/protocol/ask-question.d.ts +12 -1
  40. package/dist/protocol/ask-question.js +77 -3
  41. package/dist/protocol/create-plan.d.ts +47 -25
  42. package/dist/protocol/create-plan.js +33 -23
  43. package/dist/protocol/interactions.d.ts +26 -17
  44. package/dist/protocol/interactions.js +80 -31
  45. package/dist/protocol/messages.js +74 -14
  46. package/dist/protocol/request.d.ts +5 -0
  47. package/dist/protocol/request.js +2 -0
  48. package/dist/protocol/switch-mode.d.ts +66 -27
  49. package/dist/protocol/switch-mode.js +147 -73
  50. package/dist/protocol/token-details.d.ts +2 -0
  51. package/dist/protocol/token-details.js +11 -1
  52. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  53. package/dist/protocol/tool-call-bridge.js +14 -0
  54. package/dist/protocol/tools.d.ts +16 -1
  55. package/dist/protocol/tools.js +113 -11
  56. package/dist/replay-safety.d.ts +6 -0
  57. package/dist/replay-safety.js +56 -6
  58. package/dist/session.d.ts +35 -1
  59. package/dist/session.js +45 -1
  60. package/dist/todo-store.d.ts +2 -0
  61. package/dist/todo-store.js +19 -0
  62. package/dist/transport/connect.d.ts +2 -0
  63. package/dist/transport/connect.js +23 -6
  64. package/dist/usage.js +23 -6
  65. package/package.json +3 -2
  66. package/dist/plan-execution-kickoff.d.ts +0 -57
  67. package/dist/plan-execution-kickoff.js +0 -186
@@ -19,5 +19,11 @@ export type ReplayFrameAnalysis = {
19
19
  semanticProgress: boolean;
20
20
  barrier?: ReplayBarrierReason;
21
21
  };
22
+ /**
23
+ * Field numbers and wire types of a frame, nested three levels, without any
24
+ * payload bytes: `1:2{16:2{1:0}}`. Compare it with `agent.proto` when a frame
25
+ * is classified unknown.
26
+ */
27
+ export declare function describeFrameLayout(payload: Uint8Array, depth?: number): string;
22
28
  /** Classify one decoded server frame without performing any protocol side effects. */
23
29
  export declare function analyzeReplayFrame(payload: Uint8Array, decoded: DecodedReplayFrame): ReplayFrameAnalysis;
@@ -20,9 +20,25 @@ export class AttemptReplaySafety {
20
20
  return failure;
21
21
  }
22
22
  }
23
- const INTERACTION_UPDATE_FIELDS = new Set([1, 2, 3, 4, 7, 13, 14, 16, 17]);
23
+ // Field numbers follow Cursor CLI 2026.09.28 `agent.v1`; the extracted
24
+ // agent.proto lags the live server.
25
+ // `InteractionUpdate` oneof members this client recognizes. Besides the
26
+ // declared ones: `thinking_completed` #5 (duration), `token_delta` #8 (count),
27
+ // `tool_call_delta` #15 (argument stream, like `partial_tool_call` #7),
28
+ // `routed_model` #24 (display name) and `tool_requests_listed` #27 (count)
29
+ // carry no output and no state. Other members stay barriers.
30
+ const INTERACTION_UPDATE_FIELDS = new Set([1, 2, 3, 4, 5, 7, 8, 13, 14, 15, 16, 17, 24, 27]);
31
+ /** `InteractionUpdate.message_started_at_ms` #25: a timestamp beside the oneof member. */
32
+ const INTERACTION_UPDATE_STARTED_AT = 25;
24
33
  const INTERACTION_QUERY_FIELDS = new Set([2, 3, 4, 7, 8, 9, 10, 11, 12, 13, 14]);
25
34
  const TOP_LEVEL_FIELDS = new Set([1, 2, 3, 4, 5, 7]);
35
+ /** `AgentServerMessage.ttft_breakdown` #8: timing metrics beside the oneof member. */
36
+ const SERVER_MESSAGE_TTFT = 8;
37
+ /** At most one occurrence of an optional field, with the expected wire type. */
38
+ function optionalField(fields, fn, wt) {
39
+ const matches = fields.filter((field) => field.fn === fn);
40
+ return matches.length <= 1 && matches.every((field) => field.wt === wt);
41
+ }
26
42
  function nestedFields(topLevel, field) {
27
43
  if (topLevel?.fn !== field || topLevel.wt !== 2 || !topLevel.bytes)
28
44
  return [];
@@ -37,8 +53,14 @@ function analyzeExecWire(topLevel) {
37
53
  exactMcpState: exactVariant(36),
38
54
  };
39
55
  }
56
+ // `KvServerMessage` = id #1, oneof get #2 / set #3, `span_context` #4 (tracing);
57
+ // Cursor sends the span on live KV requests.
40
58
  function validKvWire(topLevel) {
41
- const variants = nestedFields(topLevel, 4).filter((field) => field.fn !== 1);
59
+ const fields = nestedFields(topLevel, 4);
60
+ const spans = fields.filter((field) => field.fn === 4);
61
+ if (spans.length > 1 || spans.some((field) => field.wt !== 2))
62
+ return false;
63
+ const variants = fields.filter((field) => field.fn !== 1 && field.fn !== 4);
42
64
  const variant = variants.length === 1 ? variants[0] : undefined;
43
65
  const args = variant?.wt === 2 && variant.bytes
44
66
  ? (readAllFieldsStrict(variant.bytes) ?? [])
@@ -53,14 +75,23 @@ function validKvWire(topLevel) {
53
75
  function validInteractionUpdateWire(topLevel, decoded) {
54
76
  if (!decoded)
55
77
  return true;
56
- const fields = nestedFields(topLevel, 1);
78
+ const all = nestedFields(topLevel, 1);
79
+ if (!optionalField(all, INTERACTION_UPDATE_STARTED_AT, 0))
80
+ return false;
81
+ const fields = all.filter((field) => field.fn !== INTERACTION_UPDATE_STARTED_AT);
57
82
  const update = fields.length === 1 ? fields[0] : undefined;
58
83
  if (!update || update.wt !== 2 || !INTERACTION_UPDATE_FIELDS.has(update.fn))
59
84
  return false;
60
85
  if (![1, 4].includes(update.fn))
61
86
  return true;
62
87
  const delta = update.bytes ? (readAllFieldsStrict(update.bytes) ?? []) : [];
63
- return delta.length === 1 && delta[0].fn === 1 && delta[0].wt === 2;
88
+ const text = delta.filter((field) => field.fn === 1);
89
+ // #2 is `TextDeltaUpdate.is_server_notice` (bool) or
90
+ // `ThinkingDeltaUpdate.thinking_style` (enum); both varints.
91
+ const flag = delta.filter((field) => field.fn === 2);
92
+ return text.length === 1 && text[0].wt === 2
93
+ && optionalField(delta, 2, 0)
94
+ && delta.length === text.length + flag.length;
64
95
  }
65
96
  function validInteractionQueryWire(topLevel, decoded) {
66
97
  if (!decoded)
@@ -96,10 +127,29 @@ function hasSemanticProgress(decoded) {
96
127
  || !!decoded.interactionQuery
97
128
  || !!decoded.checkpointBytes?.length;
98
129
  }
130
+ /**
131
+ * Field numbers and wire types of a frame, nested three levels, without any
132
+ * payload bytes: `1:2{16:2{1:0}}`. Compare it with `agent.proto` when a frame
133
+ * is classified unknown.
134
+ */
135
+ export function describeFrameLayout(payload, depth = 3) {
136
+ const fields = readAllFieldsStrict(payload);
137
+ if (!fields)
138
+ return "?";
139
+ return fields.map((field) => {
140
+ const nested = depth > 1 && field.wt === 2 && field.bytes && field.bytes.length > 0
141
+ ? readAllFieldsStrict(field.bytes)
142
+ : undefined;
143
+ const inner = nested && nested.length > 0 ? `{${describeFrameLayout(field.bytes, depth - 1)}}` : "";
144
+ return `${field.fn}:${field.wt}${inner}`;
145
+ }).join(",");
146
+ }
99
147
  /** Classify one decoded server frame without performing any protocol side effects. */
100
148
  export function analyzeReplayFrame(payload, decoded) {
101
- const topLevelFields = readAllFieldsStrict(payload) ?? [];
102
- const topLevel = topLevelFields.length === 1 ? topLevelFields[0] : undefined;
149
+ const allTopLevel = readAllFieldsStrict(payload) ?? [];
150
+ const ttftValid = optionalField(allTopLevel, SERVER_MESSAGE_TTFT, 2);
151
+ const topLevelFields = allTopLevel.filter((field) => field.fn !== SERVER_MESSAGE_TTFT);
152
+ const topLevel = ttftValid && topLevelFields.length === 1 ? topLevelFields[0] : undefined;
103
153
  const exec = analyzeExecWire(topLevel);
104
154
  const validKv = validKvWire(topLevel);
105
155
  const malformed = !topLevel
package/dist/session.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type CursorProviderError } from "./errors.js";
3
3
  import { type SessionActivitySource } from "./activity.js";
4
4
  import type { HostSubagentCatalog, HostToolDialect, OpencodeToolDef, ToolAliasRegistry } from "./protocol/tools.js";
5
5
  import type { CursorConversationTokenDetails } from "./protocol/token-details.js";
6
+ import type { ParallelStepState } from "./parallel-step.js";
6
7
  export type Frame = {
7
8
  flags: number;
8
9
  payload: Uint8Array;
@@ -95,6 +96,8 @@ export type CursorSession = {
95
96
  * (Cursor resets them per stream) can't cross-deliver results.
96
97
  */
97
98
  sessionId: string;
99
+ /** AgentRunRequest.run_id, for context injections bound to this Run. */
100
+ runId?: string;
98
101
  /**
99
102
  * Cursor conversation_id for this Run — used to store/echo
100
103
  * conversation_checkpoint_update (CLI parity).
@@ -152,8 +155,24 @@ export type CursorSession = {
152
155
  toolCatalog?: OpencodeToolDef[];
153
156
  /** Configured MCP server ids used to split flattened host tool names. */
154
157
  knownMcpServers?: string[];
158
+ /** The Run's model accepts images, so held-Run exec results may carry them. */
159
+ supportsImages?: boolean;
155
160
  stream: BidiStream;
156
161
  frames: AsyncIterator<Frame>;
162
+ /** An iterator read retained across an idle-guard timeout. */
163
+ pendingFrameRead?: Promise<IteratorResult<Frame>>;
164
+ /**
165
+ * One-slot pushback for a terminal update that must start the next pump pass
166
+ * after the host settles emitted tools. Timed-out reads use pendingFrameRead.
167
+ */
168
+ pushbackFrame?: Frame;
169
+ /**
170
+ * In-progress parallel tool-call step (field 27 hold). Cleared when the step
171
+ * finishes; added to priorParallelStepCallIds for late-completion filtering.
172
+ */
173
+ parallelStep?: ParallelStepState;
174
+ /** Resolved call ids from prior steps of this Run — ignore late completions. */
175
+ priorParallelStepCallIds?: Set<string>;
157
176
  pending: Map<number, PendingExec>;
158
177
  /**
159
178
  * Cursor display tool calls (tool_call_started) awaiting either an exec or a
@@ -167,6 +186,11 @@ export type CursorSession = {
167
186
  * completed list so host todos still receive a replace-all snapshot.
168
187
  */
169
188
  mirroredTodos?: Array<Record<string, unknown>>;
189
+ /**
190
+ * CreatePlan display calls whose plan was deferred to the host plan agent.
191
+ * Their completed display carries no recorded plan, so it is not mirrored.
192
+ */
193
+ deferredCreatePlanCalls?: Set<string>;
170
194
  /**
171
195
  * Legacy edit calls whose authoritative exec path is still in progress.
172
196
  * Cursor implements these as read -> whole-file write; retaining the path
@@ -302,7 +326,7 @@ export declare class SessionManager {
302
326
  * Callers must cancel the session heartbeat and wait for the old stream's
303
327
  * write chain before this, so an in-flight heartbeat cannot close the session.
304
328
  */
305
- replaceStream(session: CursorSession, next: BidiStream): void;
329
+ replaceStream(session: CursorSession, next: BidiStream, runId?: string): void;
306
330
  private subscribeTerminal;
307
331
  private key;
308
332
  close(session: CursorSession, reason?: SessionCloseReason, error?: CursorProviderError): void;
@@ -325,5 +349,15 @@ export declare class SessionManager {
325
349
  private putTombstone;
326
350
  private isTerminalReason;
327
351
  }
352
+ /**
353
+ * Read one frame, retaining ownership of the iterator read when a timeout
354
+ * wins. Every reader uses this function so a continuation cannot overtake a
355
+ * late frame (or discard its EOF/error).
356
+ */
357
+ export declare function readSessionFrame(session: CursorSession): Promise<IteratorResult<Frame>>;
358
+ export declare function readSessionFrame(session: CursorSession, timeoutMs: number): Promise<IteratorResult<Frame> | {
359
+ done: true;
360
+ timedOut: true;
361
+ }>;
328
362
  export declare const sessionManager: SessionManager;
329
363
  export {};
package/dist/session.js CHANGED
@@ -386,7 +386,7 @@ export class SessionManager {
386
386
  * Callers must cancel the session heartbeat and wait for the old stream's
387
387
  * write chain before this, so an in-flight heartbeat cannot close the session.
388
388
  */
389
- replaceStream(session, next) {
389
+ replaceStream(session, next, runId) {
390
390
  if (session.closed)
391
391
  throw new CursorProtocolError("Cannot replace stream on a closed Cursor session");
392
392
  session.heartbeatCancel?.();
@@ -395,7 +395,13 @@ export class SessionManager {
395
395
  session.deferredTerminalReason = null;
396
396
  const old = session.stream;
397
397
  session.stream = next;
398
+ if (runId !== undefined)
399
+ session.runId = runId;
398
400
  session.frames = next.frames()[Symbol.asyncIterator]();
401
+ session.pendingFrameRead = undefined;
402
+ session.pushbackFrame = undefined;
403
+ session.parallelStep = undefined;
404
+ session.priorParallelStepCallIds = undefined;
399
405
  this.subscribeTerminal(session);
400
406
  try {
401
407
  old.destroy();
@@ -444,6 +450,10 @@ export class SessionManager {
444
450
  this.putTombstone(key, reason);
445
451
  }
446
452
  session.pending.clear();
453
+ session.pendingFrameRead = undefined;
454
+ session.pushbackFrame = undefined;
455
+ session.parallelStep = undefined;
456
+ session.priorParallelStepCallIds = undefined;
447
457
  session.pumpOwner = null;
448
458
  session.pumpActive = false;
449
459
  session.displayToolCalls?.clear();
@@ -570,6 +580,40 @@ export class SessionManager {
570
580
  return !["ordinary-cleanup", "turn-ended", "initial-write-failed"].includes(reason);
571
581
  }
572
582
  }
583
+ export async function readSessionFrame(session, timeoutMs) {
584
+ if (session.pushbackFrame) {
585
+ const value = session.pushbackFrame;
586
+ session.pushbackFrame = undefined;
587
+ return { done: false, value };
588
+ }
589
+ const pending = session.pendingFrameRead ??= session.frames.next();
590
+ // A timed-out read can reject while no pump is consuming it. Preserve the
591
+ // rejection for the next reader without an unhandled detached promise.
592
+ void pending.catch(() => { });
593
+ let timer;
594
+ try {
595
+ const result = timeoutMs === undefined ? await pending : await Promise.race([
596
+ pending,
597
+ new Promise(resolve => {
598
+ timer = setTimeout(() => resolve({ done: true, timedOut: true }), timeoutMs);
599
+ timer.unref?.();
600
+ }),
601
+ ]);
602
+ if (!("timedOut" in result) && session.pendingFrameRead === pending) {
603
+ session.pendingFrameRead = undefined;
604
+ }
605
+ return result;
606
+ }
607
+ catch (error) {
608
+ if (session.pendingFrameRead === pending)
609
+ session.pendingFrameRead = undefined;
610
+ throw error;
611
+ }
612
+ finally {
613
+ if (timer)
614
+ clearTimeout(timer);
615
+ }
616
+ }
573
617
  export const sessionManager = new SessionManager();
574
618
  function installProcessCleanup() {
575
619
  const dispose = () => {
@@ -22,6 +22,8 @@ export type SessionTodo = {
22
22
  /** Normalize one host/Cursor todo item. Drops entries with empty content. */
23
23
  export declare function normalizeSessionTodo(value: unknown, index: number): SessionTodo | undefined;
24
24
  export declare function normalizeSessionTodos(value: unknown): SessionTodo[];
25
+ /** Fill omitted defaults before validation without dropping malformed entries. */
26
+ export declare function normalizeOpencodeTodoArgs(args: Record<string, unknown>): Record<string, unknown>;
25
27
  export declare function getSessionTodos(sessionID: string): SessionTodo[];
26
28
  export declare function setSessionTodos(sessionID: string, todos: unknown): SessionTodo[];
27
29
  export declare function clearSessionTodos(sessionID: string): void;
@@ -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
  }
@@ -73,6 +73,8 @@ export declare function resolveAgentOrigin(baseURL: string): string;
73
73
  export declare function closeCachedHttp2SessionsForTests(): void;
74
74
  /** Node-runtime regression hook; production callers use getSession(). */
75
75
  export declare function installSessionInvalidationForTests(origin: string, session: http2.ClientHttp2Session): void;
76
+ /** Node-runtime regression hook: cache a connected local session for `origin`. */
77
+ export declare function cacheHttp2SessionForTests(origin: string, session: http2.ClientHttp2Session): void;
76
78
  export declare function getSession(baseURL: string, options?: {
77
79
  pingTimeoutMs?: number;
78
80
  }): Promise<http2.ClientHttp2Session>;
@@ -259,6 +259,19 @@ function dropSession(origin, session) {
259
259
  if (_http2Sessions.get(origin) === session)
260
260
  _http2Sessions.delete(origin);
261
261
  }
262
+ /**
263
+ * Stop offering `session` for new Runs and close it so in-flight streams can
264
+ * drain. Used by age rotation and a failed reuse ping — never
265
+ * `destroy()`, which would abort every sibling Run multiplexed on the session
266
+ * mid-output (those Runs cannot be replayed after visible activity).
267
+ */
268
+ function retireCachedSession(origin, session) {
269
+ dropSession(origin, session);
270
+ try {
271
+ session.close();
272
+ }
273
+ catch { /* already closed */ }
274
+ }
262
275
  /** Test cleanup for local HTTP/2 fixtures; production sessions stay process-cached. */
263
276
  export function closeCachedHttp2SessionsForTests() {
264
277
  for (const session of _http2Sessions.values()) {
@@ -354,6 +367,12 @@ function installSessionInvalidation(origin, session) {
354
367
  export function installSessionInvalidationForTests(origin, session) {
355
368
  installSessionInvalidation(origin, session);
356
369
  }
370
+ /** Node-runtime regression hook: cache a connected local session for `origin`. */
371
+ export function cacheHttp2SessionForTests(origin, session) {
372
+ installSessionInvalidation(origin, session);
373
+ _http2SessionCreatedAt.set(session, Date.now());
374
+ _http2Sessions.set(origin, session);
375
+ }
357
376
  function invalidateSession(origin, session) {
358
377
  dropSession(origin, session);
359
378
  _http2SessionListenerCleanup.get(session)?.();
@@ -535,11 +554,7 @@ export function getSession(baseURL, options = {}) {
535
554
  const createdAt = _http2SessionCreatedAt.get(existing) ?? 0;
536
555
  if (!shouldReuseHttp2Session(existing, createdAt)) {
537
556
  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 */ }
557
+ retireCachedSession(origin, existing);
543
558
  }
544
559
  else {
545
560
  try {
@@ -550,8 +565,10 @@ export function getSession(baseURL, options = {}) {
550
565
  }
551
566
  }
552
567
  catch (error) {
568
+ // Sibling Runs may still be streaming on this session; destroy() would
569
+ // abort them mid-output, where they cannot be replayed.
553
570
  trace(`h2 cached session ping failed: origin=${origin} err=${error.message}`);
554
- invalidateSession(origin, existing);
571
+ retireCachedSession(origin, existing);
555
572
  }
556
573
  }
557
574
  }
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 ?? "-"}`,
@@ -164,6 +172,12 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
164
172
  `execRequests=${stats.execRequests}`,
165
173
  `createPlanInTurn=${stats.createPlanInTurn === true}`,
166
174
  `switchModeInTurn=${stats.switchModeInTurn === true}`,
175
+ ...(interactionZeroRead
176
+ ? [
177
+ "turnEndedCacheRead=zero-interaction",
178
+ "cacheReuseEvidence=unavailable",
179
+ ]
180
+ : []),
167
181
  "perModelCallCache=unavailable",
168
182
  ].join(" ");
169
183
  }
@@ -189,8 +203,9 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
189
203
  const breakdown = tokenDetails?.breakdown;
190
204
  const categorySum = breakdown?.categories.reduce((sum, category) => sum + category.estimatedTokens, 0);
191
205
  const breakdownMatch = breakdown && categorySum !== undefined
192
- ? breakdown.totalUsedTokens === tokenDetails.usedTokens &&
193
- categorySum === breakdown.totalUsedTokens
206
+ ? categorySum !== breakdown.totalUsedTokens
207
+ ? false
208
+ : currentCursorTokenBreakdown(tokenDetails) ? true : "stale"
194
209
  : undefined;
195
210
  const rawCached = counters.cacheRead + counters.cacheWrite;
196
211
  const sentCached = cacheRead + cacheWrite;
@@ -208,7 +223,7 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
208
223
  projectedOpenCodeTotal === sentTotal &&
209
224
  (cacheRatioMatch ?? true) &&
210
225
  (!tokenDetails || sentTotal === tokenDetails.usedTokens) &&
211
- (breakdownMatch ?? true)
226
+ breakdownMatch !== false
212
227
  ? "ok"
213
228
  : "mismatch";
214
229
  return [
@@ -267,7 +282,9 @@ export function occupancyValidationCounters(details, prior) {
267
282
  return {
268
283
  inputTokens: used,
269
284
  outputTokens: used > 0 ? 1 : 0,
270
- cacheRead: Math.max(0, Math.trunc(prior?.usedTokens ?? 0)),
285
+ // Cursor can shrink the context between checkpoints; the prior prefix
286
+ // cannot be larger than what is in context now.
287
+ cacheRead: Math.min(used, Math.max(0, Math.trunc(prior?.usedTokens ?? 0))),
271
288
  cacheWrite: 0,
272
289
  reasoningTokens: 0,
273
290
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -70,7 +70,8 @@
70
70
  "LICENSE",
71
71
  "DISCLAIMER.md",
72
72
  "SECURITY.md",
73
- "README.md"
73
+ "README.md",
74
+ "CHANGELOG.md"
74
75
  ],
75
76
  "scripts": {
76
77
  "build": "tsc",
@@ -1,57 +0,0 @@
1
- /**
2
- * Queue OpenCode's post-plan-exit kickoff after CreatePlan approval.
3
- *
4
- * Native `plan_exit` asks the user, then injects a synthetic user message with
5
- * `agent: "build"` so a new turn starts implementing. Our CreatePlan → question
6
- * path already asks; this module is the missing second half. A host plugin
7
- * installs the prompt handler; the language model records a request after Yes,
8
- * and the outer stream wrapper flushes it only after the current `doStream`
9
- * result stream has settled. This prevents a second generation from starting
10
- * while the continuation Run is still live.
11
- */
12
- export type PlanExecutionKickoffInput = {
13
- sessionID: string;
14
- /** Filesystem path (or relative display path) of the approved plan. */
15
- planPath: string;
16
- /** Concrete Cursor Run that owns the approval. Guards recovery/supersession. */
17
- cursorSessionID?: string;
18
- };
19
- export type PlanExecutionKickoffFn = (input: PlanExecutionKickoffInput) => void | Promise<void>;
20
- /** Upstream `PlanExitTool` wording — keep verbatim so behaviour matches OpenCode. */
21
- export declare function createPlanExecutionKickoffText(planPath: string): string;
22
- /**
23
- * Prefer a worktree-relative label when the plan sits under `workspaceRoot`,
24
- * matching OpenCode's `path.relative(instance.worktree, Session.plan(...))`.
25
- * Otherwise keep the absolute path (global data `plans/` is outside the tree).
26
- */
27
- export declare function formatPlanKickoffPath(planPath: string, workspaceRoot?: string): string;
28
- /** Decode a `file://` plan URI to a filesystem path; pass other strings through. */
29
- export declare function planPathFromUri(planUri: string): string;
30
- export type PlanExecutionKickoffState = PlanExecutionKickoffInput & {
31
- status: "pending" | "failed";
32
- attempts: number;
33
- lastError?: string;
34
- };
35
- /** Install (or clear) the host kickoff. */
36
- export declare function setPlanExecutionKickoff(fn: PlanExecutionKickoffFn | undefined): void;
37
- export declare function hasPlanExecutionKickoff(): boolean;
38
- /** Record the approved plan; execution is flushed after the stream settles. */
39
- export declare function queuePlanExecutionKickoff(input: PlanExecutionKickoffInput): boolean;
40
- /** Drop any approval state owned by a deleted host session. */
41
- export declare function cancelPlanExecutionKickoff(sessionID: string | undefined): void;
42
- /**
43
- * Run one pending kickoff only after the owning Cursor Run reached a terminal,
44
- * idle state. Returns true only after successful host handoff.
45
- */
46
- export declare function flushPlanExecutionKickoff(sessionID: string | undefined, options?: {
47
- cursorSessionID?: string;
48
- terminal?: boolean;
49
- pumpActive?: boolean;
50
- pendingExecs?: number;
51
- }): Promise<boolean>;
52
- /** Consume a user-visible warning on the next provider turn. */
53
- export declare function takePlanExecutionKickoffWarning(sessionID: string | undefined): string | undefined;
54
- /** Retry state remains explicit; no timer or automatic loop fires it. */
55
- export declare function planExecutionKickoffState(sessionID: string | undefined): PlanExecutionKickoffState | undefined;
56
- /** Test helper: clear the registered kickoff and pending approvals. */
57
- export declare function resetPlanExecutionKickoffForTests(): void;