cursor-opencode-provider 0.7.2 → 0.7.4

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 (53) hide show
  1. package/README.md +8 -5
  2. package/dist/context/agents.js +1 -1
  3. package/dist/context/build.d.ts +2 -0
  4. package/dist/context/build.js +22 -10
  5. package/dist/context/epoch.d.ts +59 -0
  6. package/dist/context/epoch.js +224 -0
  7. package/dist/context/frozen.d.ts +4 -2
  8. package/dist/context/frozen.js +31 -11
  9. package/dist/context/index.d.ts +2 -0
  10. package/dist/context/index.js +2 -0
  11. package/dist/context/overlay.d.ts +44 -0
  12. package/dist/context/overlay.js +96 -0
  13. package/dist/debug.d.ts +9 -1
  14. package/dist/debug.js +46 -5
  15. package/dist/host-event-bridge.d.ts +22 -0
  16. package/dist/host-event-bridge.js +22 -0
  17. package/dist/image-input.js +36 -11
  18. package/dist/index.d.ts +3 -0
  19. package/dist/language-model.d.ts +51 -3
  20. package/dist/language-model.js +569 -140
  21. package/dist/model-config.js +5 -1
  22. package/dist/opencode2/mcp-direct.d.ts +26 -0
  23. package/dist/opencode2/mcp-direct.js +60 -0
  24. package/dist/opencode2/types.d.ts +24 -0
  25. package/dist/plugin-opencode2.js +24 -4
  26. package/dist/plugin.js +26 -15
  27. package/dist/pricing-data.d.ts +40 -0
  28. package/dist/pricing-data.js +40 -0
  29. package/dist/protocol/ask-question.d.ts +8 -9
  30. package/dist/protocol/ask-question.js +8 -9
  31. package/dist/protocol/conversation-bind.js +18 -8
  32. package/dist/protocol/create-plan.d.ts +6 -7
  33. package/dist/protocol/create-plan.js +4 -5
  34. package/dist/protocol/messages.js +16 -1
  35. package/dist/protocol/struct.js +1 -1
  36. package/dist/protocol/switch-mode.d.ts +3 -4
  37. package/dist/protocol/switch-mode.js +21 -12
  38. package/dist/protocol/tool-call-bridge.d.ts +4 -1
  39. package/dist/protocol/tool-call-bridge.js +27 -5
  40. package/dist/protocol/tools.d.ts +4 -2
  41. package/dist/protocol/tools.js +247 -51
  42. package/dist/session-directory.d.ts +23 -4
  43. package/dist/session-directory.js +36 -4
  44. package/dist/session.d.ts +23 -3
  45. package/dist/session.js +68 -8
  46. package/dist/shared.d.ts +2 -0
  47. package/dist/shared.js +2 -0
  48. package/dist/transport/connect.js +78 -10
  49. package/dist/transport/https-proxy.d.ts +56 -0
  50. package/dist/transport/https-proxy.js +348 -0
  51. package/dist/usage.d.ts +11 -0
  52. package/dist/usage.js +33 -7
  53. package/package.json +1 -1
@@ -0,0 +1,348 @@
1
+ /**
2
+ * HTTPS proxy helpers for the Cursor Run HTTP/2 transport.
3
+ *
4
+ * Unary RPCs use Bun `fetch()`, which already honors HTTPS_PROXY / NO_PROXY.
5
+ * `node:http2` does not, so Run sessions tunnel through an HTTP CONNECT proxy
6
+ * when those env vars apply. See GitHub issue #26.
7
+ *
8
+ * Important (Bun 1.3.x): do the CONNECT handshake on a native `net.Socket`,
9
+ * then `tls.connect({ socket })` on that same socket. Do not insert a custom
10
+ * Duplex between the proxy socket and TLS — destroying such a session mid-
11
+ * handshake can segfault Bun.
12
+ */
13
+ import net from "node:net";
14
+ import tls from "node:tls";
15
+ import { CursorTransportError } from "../errors.js";
16
+ /**
17
+ * HTTPS_PROXY / https_proxy for an HTTPS target. Like curl, HTTP_PROXY is not
18
+ * consulted for HTTPS. Only `http://` proxies can carry the CONNECT tunnel;
19
+ * `https://` (TLS to the proxy) and other schemes are ignored so the Run keeps
20
+ * its direct connection rather than failing on every connect.
21
+ */
22
+ export function resolveHttpsProxyUrl(targetHost, env = process.env, targetPort = 443) {
23
+ if (!targetHost)
24
+ return undefined;
25
+ if (hostMatchesNoProxy(targetHost, env.NO_PROXY ?? env.no_proxy, targetPort))
26
+ return undefined;
27
+ const raw = (env.HTTPS_PROXY ?? env.https_proxy)?.trim();
28
+ if (!raw)
29
+ return undefined;
30
+ try {
31
+ const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw) ? raw : `http://${raw}`;
32
+ const url = new URL(withScheme);
33
+ if (url.protocol !== "http:")
34
+ return undefined;
35
+ if (!url.hostname)
36
+ return undefined;
37
+ return url;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /**
44
+ * curl/Node-style NO_PROXY matching: `*`, exact host, optional `:port`, and
45
+ * leading-dot / bare-domain suffix forms (`.corp.example` / `corp.example`).
46
+ * A port-qualified entry only bypasses that port.
47
+ */
48
+ export function hostMatchesNoProxy(targetHost, noProxy, targetPort = 443) {
49
+ if (!noProxy?.trim())
50
+ return false;
51
+ const host = targetHost.trim().toLowerCase().replace(/\.$/, "");
52
+ if (!host)
53
+ return false;
54
+ for (const part of noProxy.split(/[\s,]+/)) {
55
+ const entry = part.trim().toLowerCase();
56
+ if (!entry)
57
+ continue;
58
+ if (entry === "*")
59
+ return true;
60
+ const [entryHostRaw, entryPort] = splitHostPort(entry);
61
+ const entryHost = entryHostRaw.replace(/^\./, "").replace(/\.$/, "");
62
+ if (!entryHost)
63
+ continue;
64
+ if (entryPort !== undefined && Number(entryPort) !== targetPort)
65
+ continue;
66
+ if (host === entryHost)
67
+ return true;
68
+ if (host.endsWith(`.${entryHost}`))
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ function splitHostPort(entry) {
74
+ if (entry.startsWith("[")) {
75
+ const end = entry.indexOf("]");
76
+ if (end === -1)
77
+ return [entry, undefined];
78
+ const host = entry.slice(1, end);
79
+ const rest = entry.slice(end + 1);
80
+ if (rest.startsWith(":") && rest.length > 1)
81
+ return [host, rest.slice(1)];
82
+ return [host, undefined];
83
+ }
84
+ const idx = entry.lastIndexOf(":");
85
+ if (idx > 0 && /^\d+$/.test(entry.slice(idx + 1))) {
86
+ return [entry.slice(0, idx), entry.slice(idx + 1)];
87
+ }
88
+ return [entry, undefined];
89
+ }
90
+ /** Dialable proxy address: IPv6 literals without URL brackets, default port 80. */
91
+ export function proxyEndpoint(proxy) {
92
+ return {
93
+ host: proxy.hostname.replace(/^\[(.*)\]$/, "$1"),
94
+ port: proxy.port ? Number(proxy.port) : 80,
95
+ };
96
+ }
97
+ /** Userinfo as typed when it is not valid percent-encoding (e.g. a bare `%`). */
98
+ function decodeUserinfo(value) {
99
+ try {
100
+ return decodeURIComponent(value);
101
+ }
102
+ catch {
103
+ return value;
104
+ }
105
+ }
106
+ function proxyAuthorizationHeader(proxy) {
107
+ if (!proxy.username && !proxy.password)
108
+ return undefined;
109
+ const user = decodeUserinfo(proxy.username);
110
+ const pass = decodeUserinfo(proxy.password);
111
+ const token = Buffer.from(`${user}:${pass}`, "utf8").toString("base64");
112
+ return `Basic ${token}`;
113
+ }
114
+ /**
115
+ * Open a plain TCP connection to the proxy and establish an HTTP CONNECT tunnel
116
+ * to `targetHost:targetPort`. Returns the tunneled socket ready for TLS.
117
+ */
118
+ export async function openHttpsConnectTunnel(options) {
119
+ const targetPort = options.targetPort ?? 443;
120
+ const proxy = options.proxy;
121
+ if (proxy.protocol !== "http:") {
122
+ throw new CursorTransportError(`HTTPS CONNECT proxy must use http:// (got ${proxy.protocol}); https:// proxies are not supported`, { transient: false, replaySafe: true, code: "CURSOR_PROXY_UNSUPPORTED" });
123
+ }
124
+ const { host: proxyHost, port: proxyPort } = proxyEndpoint(proxy);
125
+ const connect = options.connect ?? net.connect;
126
+ const authority = `${options.targetHost}:${targetPort}`;
127
+ const auth = proxyAuthorizationHeader(proxy);
128
+ const request = [
129
+ `CONNECT ${authority} HTTP/1.1`,
130
+ `Host: ${authority}`,
131
+ "Proxy-Connection: keep-alive",
132
+ ...(auth ? [`Proxy-Authorization: ${auth}`] : []),
133
+ "",
134
+ "",
135
+ ].join("\r\n");
136
+ if (options.signal?.aborted) {
137
+ throw new CursorTransportError("HTTPS CONNECT tunnel aborted before connect", {
138
+ transient: true,
139
+ replaySafe: true,
140
+ code: "CURSOR_PROXY_ABORTED",
141
+ cause: options.signal.reason,
142
+ });
143
+ }
144
+ const socket = await new Promise((resolve, reject) => {
145
+ const s = connect(proxyPort, proxyHost);
146
+ const onAbort = () => {
147
+ cleanup();
148
+ try {
149
+ s.destroy();
150
+ }
151
+ catch { /* ignore */ }
152
+ reject(new CursorTransportError("HTTPS CONNECT tunnel aborted during TCP connect", {
153
+ transient: true,
154
+ replaySafe: true,
155
+ code: "CURSOR_PROXY_ABORTED",
156
+ cause: options.signal?.reason,
157
+ }));
158
+ };
159
+ const cleanup = () => {
160
+ s.removeListener("connect", onConnect);
161
+ s.removeListener("error", onError);
162
+ options.signal?.removeEventListener("abort", onAbort);
163
+ };
164
+ const onError = (error) => {
165
+ cleanup();
166
+ reject(new CursorTransportError(`HTTPS CONNECT proxy TCP connect to ${proxyHost}:${proxyPort} failed`, { transient: true, replaySafe: true, code: "CURSOR_PROXY_CONNECT_FAILED", cause: error }));
167
+ };
168
+ const onConnect = () => {
169
+ cleanup();
170
+ resolve(s);
171
+ };
172
+ options.signal?.addEventListener("abort", onAbort, { once: true });
173
+ s.once("connect", onConnect);
174
+ s.once("error", onError);
175
+ });
176
+ await new Promise((resolve, reject) => {
177
+ let settled = false;
178
+ let buffer = Buffer.alloc(0);
179
+ const onAbort = () => {
180
+ fail(new CursorTransportError("HTTPS CONNECT tunnel aborted during handshake", {
181
+ transient: true,
182
+ replaySafe: true,
183
+ code: "CURSOR_PROXY_ABORTED",
184
+ cause: options.signal?.reason,
185
+ }));
186
+ };
187
+ const cleanup = () => {
188
+ socket.removeListener("data", onData);
189
+ socket.removeListener("error", onError);
190
+ socket.removeListener("close", onClose);
191
+ options.signal?.removeEventListener("abort", onAbort);
192
+ };
193
+ const fail = (error) => {
194
+ if (settled)
195
+ return;
196
+ settled = true;
197
+ cleanup();
198
+ try {
199
+ socket.destroy();
200
+ }
201
+ catch { /* ignore */ }
202
+ reject(error);
203
+ };
204
+ const onError = (error) => {
205
+ fail(new CursorTransportError("HTTPS CONNECT tunnel socket error", {
206
+ transient: true,
207
+ replaySafe: true,
208
+ code: "CURSOR_PROXY_CONNECT_FAILED",
209
+ cause: error,
210
+ }));
211
+ };
212
+ const onClose = () => {
213
+ fail(new CursorTransportError("HTTPS CONNECT tunnel closed before response", {
214
+ transient: true,
215
+ replaySafe: true,
216
+ code: "CURSOR_PROXY_CONNECT_FAILED",
217
+ }));
218
+ };
219
+ const onData = (chunk) => {
220
+ buffer = Buffer.concat([buffer, chunk]);
221
+ const headerEnd = buffer.indexOf("\r\n\r\n");
222
+ if (headerEnd === -1) {
223
+ if (buffer.length > 64_000) {
224
+ fail(new CursorTransportError("HTTPS CONNECT response headers too large", {
225
+ transient: true,
226
+ replaySafe: true,
227
+ code: "CURSOR_PROXY_CONNECT_FAILED",
228
+ }));
229
+ }
230
+ return;
231
+ }
232
+ const headerText = buffer.subarray(0, headerEnd).toString("utf8");
233
+ const statusLine = headerText.split("\r\n", 1)[0] ?? "";
234
+ const match = /^HTTP\/\d\.\d\s+(\d{3})\b/i.exec(statusLine);
235
+ const status = match ? Number(match[1]) : NaN;
236
+ // RFC 9110 §9.3.6: any 2xx response to CONNECT establishes the tunnel.
237
+ if (!(status >= 200 && status < 300)) {
238
+ // Same classification as other HTTP failures: a proxy denial (403/407)
239
+ // will not succeed on retry; 429/5xx and unparseable replies might.
240
+ const transient = Number.isNaN(status) || status === 429 || status >= 500;
241
+ fail(new CursorTransportError(`HTTPS CONNECT to ${authority} via ${proxyHost}:${proxyPort} failed: ${statusLine || "no status"}`, { transient, replaySafe: true, code: "CURSOR_PROXY_CONNECT_REJECTED" }));
242
+ return;
243
+ }
244
+ const rest = buffer.subarray(headerEnd + 4);
245
+ if (settled)
246
+ return;
247
+ settled = true;
248
+ cleanup();
249
+ if (rest.length > 0)
250
+ socket.unshift(rest);
251
+ resolve();
252
+ };
253
+ options.signal?.addEventListener("abort", onAbort, { once: true });
254
+ socket.on("data", onData);
255
+ socket.once("error", onError);
256
+ socket.once("close", onClose);
257
+ try {
258
+ socket.write(request);
259
+ }
260
+ catch (error) {
261
+ fail(new CursorTransportError("HTTPS CONNECT request write failed", {
262
+ transient: true,
263
+ replaySafe: true,
264
+ code: "CURSOR_PROXY_CONNECT_FAILED",
265
+ cause: error,
266
+ }));
267
+ }
268
+ });
269
+ return socket;
270
+ }
271
+ /** CONNECT through the proxy, then complete TLS (ALPN h2) on the native socket. */
272
+ export async function openProxiedTlsSocket(options) {
273
+ const targetPort = options.targetPort ?? 443;
274
+ const plain = await openHttpsConnectTunnel({
275
+ proxy: options.proxy,
276
+ targetHost: options.targetHost,
277
+ targetPort,
278
+ signal: options.signal,
279
+ connect: options.connect,
280
+ });
281
+ const tlsConnect = options.tlsConnect ?? tls.connect;
282
+ return await new Promise((resolve, reject) => {
283
+ const tlsError = (cause) => new CursorTransportError(`HTTPS CONNECT TLS to ${options.targetHost} failed`, { transient: true, replaySafe: true, code: "CURSOR_PROXY_TLS_FAILED", cause });
284
+ const abortError = () => new CursorTransportError("HTTPS CONNECT TLS handshake aborted", {
285
+ transient: true,
286
+ replaySafe: true,
287
+ code: "CURSOR_PROXY_ABORTED",
288
+ cause: options.signal?.reason,
289
+ });
290
+ if (options.signal?.aborted) {
291
+ try {
292
+ plain.destroy();
293
+ }
294
+ catch { /* ignore */ }
295
+ reject(abortError());
296
+ return;
297
+ }
298
+ let socket;
299
+ try {
300
+ socket = tlsConnect({
301
+ socket: plain,
302
+ servername: options.targetHost,
303
+ ALPNProtocols: ["h2"],
304
+ });
305
+ }
306
+ catch (error) {
307
+ try {
308
+ plain.destroy();
309
+ }
310
+ catch { /* ignore */ }
311
+ reject(tlsError(error));
312
+ return;
313
+ }
314
+ let settled = false;
315
+ const cleanup = () => {
316
+ socket.removeListener("secureConnect", onSecure);
317
+ socket.removeListener("error", onError);
318
+ options.signal?.removeEventListener("abort", onAbort);
319
+ };
320
+ const fail = (error) => {
321
+ if (settled)
322
+ return;
323
+ settled = true;
324
+ cleanup();
325
+ try {
326
+ socket.destroy();
327
+ }
328
+ catch { /* ignore */ }
329
+ try {
330
+ plain.destroy();
331
+ }
332
+ catch { /* ignore */ }
333
+ reject(error);
334
+ };
335
+ const onAbort = () => fail(abortError());
336
+ const onError = (error) => fail(tlsError(error));
337
+ const onSecure = () => {
338
+ if (settled)
339
+ return;
340
+ settled = true;
341
+ cleanup();
342
+ resolve(socket);
343
+ };
344
+ options.signal?.addEventListener("abort", onAbort, { once: true });
345
+ socket.once("secureConnect", onSecure);
346
+ socket.once("error", onError);
347
+ });
348
+ }
package/dist/usage.d.ts CHANGED
@@ -29,6 +29,10 @@ export type CursorCacheDiagnosticStats = {
29
29
  stepCompletes: number;
30
30
  displayToolCalls: number;
31
31
  execRequests: number;
32
+ /** A CreatePlan interaction ran this Run (any outcome). Tags one-time upstream tools expansion. */
33
+ createPlanInTurn?: boolean;
34
+ /** A SwitchMode interaction ran this Run (any outcome). */
35
+ switchModeInTurn?: boolean;
32
36
  };
33
37
  /** Non-negative integer counter from a Cursor `turn_ended` field. */
34
38
  export declare function turnEndedCounter(te: Record<string, unknown>, key: string): number;
@@ -64,6 +68,13 @@ export declare const OPENCODE_DISPLAY_ONLY_COST_METADATA: {
64
68
  readonly totalNanoAiu: 0;
65
69
  };
66
70
  };
71
+ /**
72
+ * Counters that mirror {@link occupancyUsageFromTokenDetails} for
73
+ * {@link formatTurnUsageValidation}. Always validate occupancy finishes —
74
+ * including TurnEnded/stop — against these, not against aggregate TurnEnded
75
+ * request counters. Request cache ratios stay on `finish:` / cache diagnosis.
76
+ */
77
+ export declare function occupancyValidationCounters(details: CursorConversationTokenDetails, prior?: CursorConversationTokenDetails): CursorUsageCounters;
67
78
  export declare function occupancyUsageFromTokenDetails(details: CursorConversationTokenDetails, prior?: CursorConversationTokenDetails): LanguageModelV3Usage;
68
79
  /** Project nested V3 usage into the common flat AI-SDK counter shape. */
69
80
  export declare function flatUsageFromV3(usage: LanguageModelV3Usage): {
package/dist/usage.js CHANGED
@@ -119,6 +119,19 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
119
119
  ? prior ? "warm" : "checkpoint-without-token-details"
120
120
  : "cold";
121
121
  const contextDelta = current && prior ? current.usedTokens - prior.usedTokens : undefined;
122
+ const toolsDelta = categoryDelta.tools;
123
+ // When our RequestContext tools overlay bytes are unchanged but Cursor's
124
+ // tools category still moves, the churn is upstream accounting (e.g. plan
125
+ // mode / CreatePlan internals), not a client prefix rebuild.
126
+ const toolsCategoryChurn = stats.requestContextReused
127
+ && typeof toolsDelta === "number"
128
+ && toolsDelta !== 0
129
+ ? "upstream-stable-overlay"
130
+ : !stats.requestContextReused
131
+ && typeof toolsDelta === "number"
132
+ && toolsDelta !== 0
133
+ ? "client-overlay-changed"
134
+ : "none";
122
135
  return [
123
136
  "cache diagnosis:",
124
137
  `sessionKey=${stats.sessionKey ?? "-"}`,
@@ -138,6 +151,7 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
138
151
  `rawReadVsPriorContext=${prior ? usageRatio(rawRead, prior.usedTokens) : "n/a"}`,
139
152
  `sameSizedCategoryTokens=${categoriesComparable ? sameSizedCategoryTokens : "unavailable"}`,
140
153
  `categoryDelta=${categoriesComparable && Object.keys(categoryDelta).length > 0 ? JSON.stringify(categoryDelta) : "unavailable"}`,
154
+ `toolsCategoryChurn=${toolsCategoryChurn}`,
141
155
  `requestContext=${stats.requestContextReused ? "reused" : "built"}`,
142
156
  `requestContextHash=${stats.requestContextHash.slice(0, 16)}`,
143
157
  `systemPromptHash=${stats.systemPromptHash?.slice(0, 16) ?? "none"}`,
@@ -148,6 +162,8 @@ export function formatCursorCacheDiagnostics(counters, current, prior, stats) {
148
162
  `steps=${stats.stepStarts}/${stats.stepCompletes}`,
149
163
  `displayToolCalls=${stats.displayToolCalls}`,
150
164
  `execRequests=${stats.execRequests}`,
165
+ `createPlanInTurn=${stats.createPlanInTurn === true}`,
166
+ `switchModeInTurn=${stats.switchModeInTurn === true}`,
151
167
  "perModelCallCache=unavailable",
152
168
  ].join(" ");
153
169
  }
@@ -240,17 +256,27 @@ export function emptyLanguageModelV3Usage() {
240
256
  export const OPENCODE_DISPLAY_ONLY_COST_METADATA = {
241
257
  copilot: { totalNanoAiu: 0 },
242
258
  };
243
- export function occupancyUsageFromTokenDetails(details, prior) {
259
+ /**
260
+ * Counters that mirror {@link occupancyUsageFromTokenDetails} for
261
+ * {@link formatTurnUsageValidation}. Always validate occupancy finishes —
262
+ * including TurnEnded/stop — against these, not against aggregate TurnEnded
263
+ * request counters. Request cache ratios stay on `finish:` / cache diagnosis.
264
+ */
265
+ export function occupancyValidationCounters(details, prior) {
244
266
  const used = Math.max(0, Math.trunc(details.usedTokens));
245
- if (used <= 0)
246
- return emptyLanguageModelV3Usage();
247
- return buildLanguageModelV3UsageFromCounters({
267
+ return {
248
268
  inputTokens: used,
249
- outputTokens: 1,
250
- cacheRead: prior?.usedTokens ?? 0,
269
+ outputTokens: used > 0 ? 1 : 0,
270
+ cacheRead: Math.max(0, Math.trunc(prior?.usedTokens ?? 0)),
251
271
  cacheWrite: 0,
252
272
  reasoningTokens: 0,
253
- }, {
273
+ };
274
+ }
275
+ export function occupancyUsageFromTokenDetails(details, prior) {
276
+ const used = Math.max(0, Math.trunc(details.usedTokens));
277
+ if (used <= 0)
278
+ return emptyLanguageModelV3Usage();
279
+ return buildLanguageModelV3UsageFromCounters(occupancyValidationCounters(details, prior), {
254
280
  contextTotalTokens: used,
255
281
  priorContextTokens: prior?.usedTokens,
256
282
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",