cursor-opencode-provider 0.7.6 → 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 (101) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +36 -15
  3. package/SECURITY.md +43 -7
  4. package/dist/agent-url.js +2 -2
  5. package/dist/auth-renewal.d.ts +85 -0
  6. package/dist/auth-renewal.js +284 -0
  7. package/dist/auth.d.ts +38 -22
  8. package/dist/auth.js +79 -96
  9. package/dist/classic-tools.d.ts +15 -0
  10. package/dist/classic-tools.js +45 -0
  11. package/dist/context/build.d.ts +62 -7
  12. package/dist/context/build.js +127 -95
  13. package/dist/context/dynamic-catalog.d.ts +2 -58
  14. package/dist/context/dynamic-catalog.js +16 -161
  15. package/dist/context/epoch.d.ts +6 -4
  16. package/dist/context/epoch.js +22 -10
  17. package/dist/context/frozen.js +42 -15
  18. package/dist/context/index.d.ts +3 -1
  19. package/dist/context/index.js +3 -1
  20. package/dist/context/overlay.d.ts +2 -23
  21. package/dist/context/overlay.js +6 -30
  22. package/dist/context/paths.d.ts +51 -21
  23. package/dist/context/paths.js +78 -27
  24. package/dist/context/rules.d.ts +3 -15
  25. package/dist/context/rules.js +10 -186
  26. package/dist/context/skills-bridge.d.ts +22 -0
  27. package/dist/context/skills-bridge.js +20 -0
  28. package/dist/context/skills.d.ts +31 -8
  29. package/dist/context/skills.js +187 -86
  30. package/dist/debug.d.ts +2 -0
  31. package/dist/debug.js +4 -0
  32. package/dist/errors.d.ts +13 -0
  33. package/dist/errors.js +27 -3
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +119 -15
  36. package/dist/host-plan-file.d.ts +28 -0
  37. package/dist/host-plan-file.js +76 -0
  38. package/dist/image-input.d.ts +18 -0
  39. package/dist/image-input.js +119 -28
  40. package/dist/image-save.d.ts +10 -2
  41. package/dist/image-save.js +35 -23
  42. package/dist/index.d.ts +10 -0
  43. package/dist/language-model.d.ts +86 -25
  44. package/dist/language-model.js +1295 -369
  45. package/dist/model-config.d.ts +9 -0
  46. package/dist/model-config.js +28 -1
  47. package/dist/opencode2/catalog.d.ts +1 -0
  48. package/dist/opencode2/catalog.js +2 -0
  49. package/dist/opencode2/image-save-tool.d.ts +8 -0
  50. package/dist/opencode2/image-save-tool.js +84 -0
  51. package/dist/opencode2/integration.d.ts +30 -7
  52. package/dist/opencode2/integration.js +75 -46
  53. package/dist/opencode2/types.d.ts +15 -0
  54. package/dist/parallel-step.d.ts +65 -0
  55. package/dist/parallel-step.js +102 -0
  56. package/dist/plugin-opencode2.js +58 -56
  57. package/dist/plugin.d.ts +0 -10
  58. package/dist/plugin.js +207 -127
  59. package/dist/pricing-data.d.ts +14 -1
  60. package/dist/pricing-data.js +14 -1
  61. package/dist/protocol/ask-question.d.ts +12 -1
  62. package/dist/protocol/ask-question.js +77 -3
  63. package/dist/protocol/blob-store.js +3 -2
  64. package/dist/protocol/conversation-bind.js +1 -1
  65. package/dist/protocol/conversation-persistence.d.ts +2 -0
  66. package/dist/protocol/conversation-persistence.js +11 -1
  67. package/dist/protocol/conversation-state.d.ts +2 -0
  68. package/dist/protocol/conversation-state.js +19 -0
  69. package/dist/protocol/create-plan.d.ts +47 -25
  70. package/dist/protocol/create-plan.js +35 -24
  71. package/dist/protocol/interactions.d.ts +26 -17
  72. package/dist/protocol/interactions.js +80 -31
  73. package/dist/protocol/messages.js +96 -18
  74. package/dist/protocol/request.d.ts +16 -9
  75. package/dist/protocol/request.js +17 -25
  76. package/dist/protocol/switch-mode.d.ts +66 -27
  77. package/dist/protocol/switch-mode.js +147 -73
  78. package/dist/protocol/token-details.d.ts +2 -0
  79. package/dist/protocol/token-details.js +11 -1
  80. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  81. package/dist/protocol/tool-call-bridge.js +14 -0
  82. package/dist/protocol/tools.d.ts +33 -11
  83. package/dist/protocol/tools.js +165 -103
  84. package/dist/protocol/turn-provenance.d.ts +52 -0
  85. package/dist/protocol/turn-provenance.js +174 -0
  86. package/dist/replay-safety.d.ts +6 -0
  87. package/dist/replay-safety.js +56 -6
  88. package/dist/session.d.ts +44 -2
  89. package/dist/session.js +45 -1
  90. package/dist/shared.d.ts +7 -0
  91. package/dist/shared.js +7 -0
  92. package/dist/todo-store.d.ts +2 -0
  93. package/dist/todo-store.js +19 -0
  94. package/dist/transport/connect.d.ts +2 -0
  95. package/dist/transport/connect.js +23 -6
  96. package/dist/usage.js +23 -6
  97. package/package.json +3 -2
  98. package/dist/context/agents.d.ts +0 -12
  99. package/dist/context/agents.js +0 -136
  100. package/dist/plan-execution-kickoff.d.ts +0 -57
  101. package/dist/plan-execution-kickoff.js +0 -186
@@ -0,0 +1,174 @@
1
+ // A sticky Cursor conversation only knows what Cursor itself produced. When the
2
+ // host answers a turn with another model (another provider or a local model)
3
+ // and then comes back, resuming the old checkpoint hides that work from Cursor.
4
+ // A direct switch between two Cursor models is not foreign history: like the
5
+ // Cursor CLI, the next Run resumes the same conversation with the new model.
6
+ // Remember what this provider emitted in its most recent step per OpenCode
7
+ // session so the next fresh Run can tell whether the host's latest assistant
8
+ // turn is ours.
9
+ /** Mirrors MAX_TURN_STATE_SESSIONS; evicted sessions re-hydrate from disk. */
10
+ export const MAX_PROVENANCE_SESSIONS = 256;
11
+ // Per-session footprint stays small: a step is identified by its first tool
12
+ // call ids and the first 4 KiB of its text (and of its reasoning plus text),
13
+ // which is enough to tell turns apart.
14
+ const MAX_TOOL_CALL_IDS = 64;
15
+ const MAX_TEXT_CHARS = 4 * 1024;
16
+ const provenanceBySession = new Map();
17
+ function normalizeText(text) {
18
+ return text.replace(/\s+/g, "");
19
+ }
20
+ function touch(sessionKey, entry) {
21
+ provenanceBySession.delete(sessionKey);
22
+ provenanceBySession.set(sessionKey, entry);
23
+ while (provenanceBySession.size > MAX_PROVENANCE_SESSIONS) {
24
+ const oldest = provenanceBySession.keys().next().value;
25
+ if (!oldest)
26
+ break;
27
+ provenanceBySession.delete(oldest);
28
+ }
29
+ return entry;
30
+ }
31
+ function entryFor(sessionKey, conversationId) {
32
+ const existing = provenanceBySession.get(sessionKey);
33
+ if (existing && existing.conversationId === conversationId)
34
+ return touch(sessionKey, existing);
35
+ return touch(sessionKey, { conversationId, toolCallIds: [], text: "", textWithReasoning: "", stepPending: false });
36
+ }
37
+ function appendCapped(value, delta) {
38
+ return value.length < MAX_TEXT_CHARS ? (value + delta).slice(0, MAX_TEXT_CHARS) : value;
39
+ }
40
+ function contentEntry(sessionKey, conversationId) {
41
+ const entry = entryFor(sessionKey, conversationId);
42
+ if (entry.stepPending) {
43
+ entry.stepPending = false;
44
+ entry.toolCallIds = [];
45
+ entry.text = "";
46
+ entry.textWithReasoning = "";
47
+ }
48
+ return entry;
49
+ }
50
+ /**
51
+ * Mark the start of one host step (one doStream). The previous step stays
52
+ * recorded until this one emits content, because hosts drop empty assistant
53
+ * turns from history.
54
+ */
55
+ export function beginEmittedStep(sessionKey, conversationId) {
56
+ entryFor(sessionKey, conversationId).stepPending = true;
57
+ }
58
+ /** Record one stream part this provider handed to the host. */
59
+ export function recordEmittedPart(sessionKey, conversationId, part) {
60
+ if ((part.type === "text-delta" || part.type === "reasoning-delta") && typeof part.delta === "string") {
61
+ const delta = normalizeText(part.delta);
62
+ if (!delta)
63
+ return;
64
+ const entry = contentEntry(sessionKey, conversationId);
65
+ entry.textWithReasoning = appendCapped(entry.textWithReasoning, delta);
66
+ if (part.type === "text-delta")
67
+ entry.text = appendCapped(entry.text, delta);
68
+ return;
69
+ }
70
+ if (part.type === "tool-call" && typeof part.toolCallId === "string" && part.toolCallId) {
71
+ const entry = contentEntry(sessionKey, conversationId);
72
+ if (entry.toolCallIds.length < MAX_TOOL_CALL_IDS)
73
+ entry.toolCallIds.push(part.toolCallId);
74
+ }
75
+ }
76
+ /**
77
+ * Bind provenance to the conversation a Run was opened on. A reminted
78
+ * conversation starts with an empty record, so it never inherits the previous
79
+ * conversation's steps.
80
+ */
81
+ export function trackTurnProvenance(sessionKey, conversationId) {
82
+ entryFor(sessionKey, conversationId);
83
+ }
84
+ export function getTurnProvenance(sessionKey) {
85
+ const entry = provenanceBySession.get(sessionKey);
86
+ if (!entry)
87
+ return undefined;
88
+ return {
89
+ conversationId: entry.conversationId,
90
+ toolCallIds: [...entry.toolCallIds],
91
+ text: entry.text,
92
+ textWithReasoning: entry.textWithReasoning,
93
+ };
94
+ }
95
+ export function restoreTurnProvenance(sessionKey, value) {
96
+ touch(sessionKey, { ...value, toolCallIds: [...value.toolCallIds], stepPending: false });
97
+ }
98
+ export function resetTurnProvenanceForTests() {
99
+ provenanceBySession.clear();
100
+ }
101
+ export function serializeTurnProvenance(value) {
102
+ return JSON.stringify(value);
103
+ }
104
+ export function parseTurnProvenance(raw) {
105
+ try {
106
+ const value = JSON.parse(raw);
107
+ if (typeof value.conversationId !== "string" || !value.conversationId)
108
+ return undefined;
109
+ return {
110
+ conversationId: value.conversationId,
111
+ toolCallIds: Array.isArray(value.toolCallIds)
112
+ ? value.toolCallIds.filter((id) => typeof id === "string").slice(0, MAX_TOOL_CALL_IDS)
113
+ : [],
114
+ text: typeof value.text === "string" ? value.text.slice(0, MAX_TEXT_CHARS) : "",
115
+ textWithReasoning: typeof value.textWithReasoning === "string"
116
+ ? value.textWithReasoning.slice(0, MAX_TEXT_CHARS)
117
+ : "",
118
+ };
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ /**
125
+ * Decide whether the host history moved past this Cursor conversation.
126
+ * Returns undefined when there is no evidence either way (no record for this
127
+ * conversation, or no assistant turn yet), so unknown state never forces a
128
+ * rebase.
129
+ */
130
+ export function detectForeignHistory(input) {
131
+ if (!input.sessionKey)
132
+ return undefined;
133
+ const entry = provenanceBySession.get(input.sessionKey);
134
+ if (!entry || entry.conversationId !== input.conversationId)
135
+ return undefined;
136
+ let lastAssistant;
137
+ for (let i = input.prompt.length - 1; i >= 0; i--) {
138
+ if (input.prompt[i].role === "assistant") {
139
+ lastAssistant = input.prompt[i];
140
+ break;
141
+ }
142
+ }
143
+ if (!lastAssistant || !Array.isArray(lastAssistant.content))
144
+ return undefined;
145
+ const toolCallIds = [];
146
+ let text = "";
147
+ let textWithReasoning = "";
148
+ for (const part of lastAssistant.content) {
149
+ if (part.type === "tool-call" && typeof part.toolCallId === "string")
150
+ toolCallIds.push(part.toolCallId);
151
+ if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
152
+ const normalized = normalizeText(part.text);
153
+ textWithReasoning += normalized;
154
+ if (part.type === "text")
155
+ text += normalized;
156
+ }
157
+ }
158
+ // A turn carrying only typed reasoning gives no evidence either way.
159
+ if (toolCallIds.length === 0 && !text)
160
+ return undefined;
161
+ if (toolCallIds.some((id) => entry.toolCallIds.includes(id)))
162
+ return undefined;
163
+ // Compare against the latest step only: a foreign "Done." must not match an
164
+ // older Cursor turn that happened to contain the same words. Same model: the
165
+ // host keeps our reasoning typed, so its text parts match our text. After a
166
+ // model switch OpenCode 1.x replays that reasoning as text parts in place,
167
+ // so the turn matches our reasoning and text in emission order instead.
168
+ if (entry.text && text.slice(0, MAX_TEXT_CHARS) === entry.text)
169
+ return undefined;
170
+ if (entry.textWithReasoning && textWithReasoning.slice(0, MAX_TEXT_CHARS) === entry.textWithReasoning) {
171
+ return undefined;
172
+ }
173
+ return "foreign-assistant";
174
+ }
@@ -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).
@@ -140,12 +143,36 @@ export type CursorSession = {
140
143
  hostAgent?: string;
141
144
  /** Stable host-system + provider-guidance identity for restart validation. */
142
145
  stableSystemPromptHash?: string;
146
+ /** Fresh turn resumed from a stored checkpoint; a blob miss may reseed it. */
147
+ checkpointRebaseEligible?: boolean;
143
148
  /** Completed compaction must rebase once before resuming a normal agent. */
144
149
  postCompactionRebase?: boolean;
145
- /** Last real host catalog, retained only as a lifecycle-turn fallback. */
150
+ /**
151
+ * Epoch-held host catalog for this OpenCode session. Refreshed on every
152
+ * `doStream` (including held-Run continuation) so exec #36 and permission
153
+ * can see tools that connected after Run open. Also the lifecycle-turn fallback.
154
+ */
146
155
  toolCatalog?: OpencodeToolDef[];
156
+ /** Configured MCP server ids used to split flattened host tool names. */
157
+ knownMcpServers?: string[];
158
+ /** The Run's model accepts images, so held-Run exec results may carry them. */
159
+ supportsImages?: boolean;
147
160
  stream: BidiStream;
148
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>;
149
176
  pending: Map<number, PendingExec>;
150
177
  /**
151
178
  * Cursor display tool calls (tool_call_started) awaiting either an exec or a
@@ -159,6 +186,11 @@ export type CursorSession = {
159
186
  * completed list so host todos still receive a replace-all snapshot.
160
187
  */
161
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>;
162
194
  /**
163
195
  * Legacy edit calls whose authoritative exec path is still in progress.
164
196
  * Cursor implements these as read -> whole-file write; retaining the path
@@ -294,7 +326,7 @@ export declare class SessionManager {
294
326
  * Callers must cancel the session heartbeat and wait for the old stream's
295
327
  * write chain before this, so an in-flight heartbeat cannot close the session.
296
328
  */
297
- replaceStream(session: CursorSession, next: BidiStream): void;
329
+ replaceStream(session: CursorSession, next: BidiStream, runId?: string): void;
298
330
  private subscribeTerminal;
299
331
  private key;
300
332
  close(session: CursorSession, reason?: SessionCloseReason, error?: CursorProviderError): void;
@@ -317,5 +349,15 @@ export declare class SessionManager {
317
349
  private putTombstone;
318
350
  private isTerminalReason;
319
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
+ }>;
320
362
  export declare const sessionManager: SessionManager;
321
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 = () => {
package/dist/shared.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  export declare const CURSOR_API_HOST = "api2.cursor.sh";
2
2
  export declare const CURSOR_WEBSITE_HOST = "cursor.com";
3
+ /**
4
+ * Production OAuth client id Cursor's IDE sends on `POST /oauth/token`
5
+ * (Cursor 3.17.19 `workbench.desktop.main.js`, `cursorCreds.authClientId` for
6
+ * `backendUrl` `https://api2.cursor.sh`). The IDE's non-production id is not
7
+ * used: a custom `CURSOR_API_BASE_URL` keeps this one.
8
+ */
9
+ export declare const CURSOR_OAUTH_CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
3
10
  export declare const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
4
11
  export declare const CURSOR_PROVIDER_ID = "cursor";
5
12
  /** Private provider option injected by the OpenCode plugin for summary turns. */
package/dist/shared.js CHANGED
@@ -1,5 +1,12 @@
1
1
  export const CURSOR_API_HOST = "api2.cursor.sh";
2
2
  export const CURSOR_WEBSITE_HOST = "cursor.com";
3
+ /**
4
+ * Production OAuth client id Cursor's IDE sends on `POST /oauth/token`
5
+ * (Cursor 3.17.19 `workbench.desktop.main.js`, `cursorCreds.authClientId` for
6
+ * `backendUrl` `https://api2.cursor.sh`). The IDE's non-production id is not
7
+ * used: a custom `CURSOR_API_BASE_URL` keeps this one.
8
+ */
9
+ export const CURSOR_OAUTH_CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB";
3
10
  export const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
4
11
  export const CURSOR_PROVIDER_ID = "cursor";
5
12
  /** Private provider option injected by the OpenCode plugin for summary turns. */
@@ -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
  };