dsh-cursor-subscription 0.5.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.
package/lib/index.js ADDED
@@ -0,0 +1,2883 @@
1
+ /**
2
+ * dsh-cursor-subscription — use a Cursor subscription inside DeepSeek Harness.
3
+ *
4
+ * Host side: PKCE browser login against Cursor's OAuth endpoints, token
5
+ * refresh, an `LlmAdapter` that speaks the Cursor Agent protocol
6
+ * (`agent.v1.AgentService/Run` over HTTP/2 with Connect framing), model
7
+ * discovery via `GetUsableModels`, and a loopback RPC channel for the web
8
+ * client.
9
+ *
10
+ * The Cursor Agent protocol is reverse-engineered and undocumented; wire
11
+ * details are kept in the `proto.js` companion module and the message
12
+ * builders/parsers below. Field numbers were verified against the live API
13
+ * and the vendored `agent.v1` protobuf schemas published by community
14
+ * projects (see README).
15
+ */
16
+ import z from "@deepseek-ai/schemastery";
17
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
18
+ import { ToolCallId, LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
19
+ import { createHash, randomUUID } from "node:crypto";
20
+ import http2 from "node:http2";
21
+ import http from "node:http";
22
+ import https from "node:https";
23
+ import { spawn } from "node:child_process";
24
+ import { gunzipSync } from "node:zlib";
25
+ import { encodeValue, decodeValue, Reader, Writer } from "./proto.js";
26
+
27
+ //#region native fetch
28
+ /**
29
+ * DSH web replaces the ambient `fetch` with an MCP `createFetchWithInit`
30
+ * wrapper whose `baseFetch` is undefined in the host process, so any consumer
31
+ * of the global `fetch` throws `baseFetch is not a function`. Cursor usage /
32
+ * refresh / model discovery go through HTTP, so this module uses Node's own
33
+ * `http`/`https` to talk to the upstream directly instead of trusting the
34
+ * host-injected `fetch`. It exposes the small Fetch subset these callers use.
35
+ */
36
+ export async function nativeFetch(url, init = {}) {
37
+ const target = new URL(url);
38
+ const client = target.protocol === "https:" ? https : http;
39
+ const method = (init.method ?? "GET").toUpperCase();
40
+ const body = init.body === undefined ? undefined : isBytes(init.body) ? Buffer.from(init.body) : String(init.body);
41
+
42
+ const request = (u, redirect) =>
43
+ new Promise((resolve, reject) => {
44
+ const req = client.request(
45
+ u,
46
+ {
47
+ method,
48
+ headers: sanitizeHeaders(init.headers),
49
+ signal: init.signal,
50
+ },
51
+ (res) => {
52
+ const chunks = [];
53
+ res.on("data", (chunk) => chunks.push(chunk));
54
+ res.on("end", () => {
55
+ const data = Buffer.concat(chunks);
56
+ const status = res.statusCode ?? 0;
57
+ if ((redirect === "error" || redirect === "manual") && status >= 300 && status < 400) {
58
+ reject(new Error(`HTTP ${status} from ${u.origin}${u.pathname}`));
59
+ return;
60
+ }
61
+ const location = res.headers.location;
62
+ if ((redirect ?? "follow") === "follow" && location && status >= 300 && status < 400) {
63
+ const next = new URL(location, u);
64
+ resolve({ redirect: next });
65
+ return;
66
+ }
67
+ resolve({
68
+ response: makeResponse(status, res, data),
69
+ });
70
+ });
71
+ },
72
+ );
73
+ req.on("error", reject);
74
+ if (body !== undefined) req.write(body);
75
+ req.end();
76
+ });
77
+
78
+ let u = target;
79
+ for (let hops = 0; ; hops++) {
80
+ const result = await request(u, init.redirect);
81
+ if (result.redirect !== undefined) {
82
+ if (hops >= 6) throw new Error(`too many redirects from ${target.href}`);
83
+ u = result.redirect;
84
+ continue;
85
+ }
86
+ return result.response;
87
+ }
88
+ }
89
+
90
+ function isBytes(value) {
91
+ return value instanceof Uint8Array || (typeof value !== "string" && ArrayBuffer.isView(value));
92
+ }
93
+
94
+ function sanitizeHeaders(headers) {
95
+ if (headers === undefined) return undefined;
96
+ const out = {};
97
+ for (const [key, value] of Object.entries(headers)) {
98
+ if (value === undefined) continue;
99
+ out[key] = value;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ function makeResponse(status, res, data) {
105
+ const text = () => data.toString("utf8");
106
+ return {
107
+ ok: status >= 200 && status < 300,
108
+ status,
109
+ statusText: res.statusMessage ?? "",
110
+ headers: {
111
+ get: (name) => res.headers[String(name).toLowerCase()] ?? null,
112
+ },
113
+ text: async () => text(),
114
+ json: async () => JSON.parse(text()),
115
+ arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
116
+ };
117
+ }
118
+ //#endregion
119
+
120
+ //#region constants
121
+ export const name = "cursor-subscription";
122
+ export const inject = ["llm", "credentials", "connection"];
123
+
124
+ export const PROVIDER = "cursor-subscription";
125
+ export const CREDENTIAL_REF = credentialRef("CURSOR_SUBSCRIPTION_OAUTH");
126
+ export const CHANNEL = "/cursor-subscription";
127
+
128
+ export const CURSOR_BASE_URL = "https://api2.cursor.sh";
129
+ export const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
130
+ export const CURSOR_AUTH_ORIGIN = "https://cursor.com";
131
+ export const CURSOR_POLL_PATH = "/auth/poll";
132
+ export const CURSOR_REFRESH_PATH = "/auth/exchange_user_api_key";
133
+ export const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run";
134
+ export const CURSOR_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
135
+
136
+ /** Cursor dashboard usage endpoints (authenticated with the session cookie). */
137
+ export const USAGE_API_ORIGIN = "https://cursor.com";
138
+ export const USAGE_URL = `${USAGE_API_ORIGIN}/api/usage`;
139
+ export const USAGE_SUMMARY_URL = `${USAGE_API_ORIGIN}/api/usage-summary`;
140
+ export const USAGE_TEAMS_URL = `${USAGE_API_ORIGIN}/api/dashboard/teams`;
141
+ export const USAGE_AGGREGATED_URL = `${USAGE_API_ORIGIN}/api/dashboard/get-aggregated-usage-events`;
142
+ export const USAGE_TTL_MS = 60 * 1000;
143
+ /** Cap the per-model spend list returned to the settings panel. */
144
+ export const MAX_USAGE_MODELS = 20;
145
+
146
+ /** Client version reported to the Agent service; bump when Cursor requires it. */
147
+ export const CURSOR_CLIENT_VERSION = "cli-2026.02.13-41ac335";
148
+ /** Keep-alive heartbeats while an agent run is streaming. */
149
+ export const HEARTBEAT_INTERVAL_MS = 5000;
150
+ /** How long an agent run may stay completely silent before we abort. */
151
+ export const STREAM_IDLE_TIMEOUT_MS = 120 * 1000;
152
+ /** How long a run may keep sending heartbeats without any real content. */
153
+ export const STREAM_PROGRESS_TIMEOUT_MS = 60 * 1000;
154
+ /** Retain checkpoints/live tool bridges for inactive DSH sessions. */
155
+ export const SESSION_STATE_TTL_MS = 30 * 60 * 1000;
156
+ /** Stop one Cursor Run before an unconstrained agent can loop forever. */
157
+ export const MAX_TOOL_ROUNDS = 64;
158
+ export const DEFAULT_RETRY_COUNT = 0;
159
+ export const DEFAULT_RETRY_INTERVAL_MS = 1000;
160
+ export const DEFAULT_RETRY_HTTP_STATUS_CODES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
161
+ export const SETTINGS_NAMESPACE = "cursor-subscription";
162
+ export const Config = z.object({
163
+ maxToolRounds: z.number().step(1).min(1).max(1000).default(MAX_TOOL_ROUNDS),
164
+ retryCount: z.number().step(1).min(0).max(10).default(DEFAULT_RETRY_COUNT),
165
+ retryIntervalMs: z.number().step(1).min(0).max(300_000).default(DEFAULT_RETRY_INTERVAL_MS),
166
+ retryHttpStatusCodes: z.array(z.number().step(1).min(400).max(599)).default([...DEFAULT_RETRY_HTTP_STATUS_CODES]),
167
+ });
168
+ /** Refresh the access token this early before its JWT expiry. */
169
+ export const REFRESH_AHEAD_MS = 5 * 60 * 1000;
170
+ /** Default access-token lifetime when the JWT has no usable exp claim. */
171
+ export const DEFAULT_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
172
+
173
+ export const DEFAULT_CONTEXT_WINDOW = 200000;
174
+ export const DEFAULT_MAX_TOKENS = 64000;
175
+
176
+ export function resolveCursorSettings(input = {}) {
177
+ const maxToolRounds = input.maxToolRounds ?? MAX_TOOL_ROUNDS;
178
+ const retryCount = input.retryCount ?? DEFAULT_RETRY_COUNT;
179
+ const retryIntervalMs = input.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS;
180
+ const retryHttpStatusCodes = input.retryHttpStatusCodes ?? DEFAULT_RETRY_HTTP_STATUS_CODES;
181
+ if (!Number.isSafeInteger(maxToolRounds) || maxToolRounds < 1 || maxToolRounds > 1000) {
182
+ throw new Error("cursor-subscription: maxToolRounds must be an integer between 1 and 1000");
183
+ }
184
+ if (!Number.isSafeInteger(retryCount) || retryCount < 0 || retryCount > 10) {
185
+ throw new Error("cursor-subscription: retryCount must be an integer between 0 and 10");
186
+ }
187
+ if (!Number.isSafeInteger(retryIntervalMs) || retryIntervalMs < 0 || retryIntervalMs > 300_000) {
188
+ throw new Error("cursor-subscription: retryIntervalMs must be an integer between 0 and 300000");
189
+ }
190
+ if (!Array.isArray(retryHttpStatusCodes)
191
+ || retryHttpStatusCodes.some((status) => !Number.isSafeInteger(status) || status < 400 || status > 599)) {
192
+ throw new Error("cursor-subscription: retryHttpStatusCodes must contain only HTTP status integers from 400 to 599");
193
+ }
194
+ if (new Set(retryHttpStatusCodes).size !== retryHttpStatusCodes.length) {
195
+ throw new Error("cursor-subscription: retryHttpStatusCodes must not contain duplicates");
196
+ }
197
+ return Object.freeze({
198
+ maxToolRounds,
199
+ retryCount,
200
+ retryIntervalMs,
201
+ retryHttpStatusCodes: Object.freeze([...retryHttpStatusCodes]),
202
+ });
203
+ }
204
+
205
+ export function shouldRetryHttpStatus(status, retriesUsed, settings) {
206
+ return retriesUsed < settings.retryCount && settings.retryHttpStatusCodes.includes(status);
207
+ }
208
+
209
+ export function isSuccessfulAgentResponse(status, contentType) {
210
+ return status === 200 && /^application\/connect\+proto(?:\s*;|\s*$)/i.test(contentType ?? "");
211
+ }
212
+
213
+ function abortableDelay(ms, signal) {
214
+ if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Cursor retry aborted"));
215
+ if (ms === 0) return Promise.resolve();
216
+ return new Promise((resolve, reject) => {
217
+ const timer = setTimeout(done, ms);
218
+ timer.unref?.();
219
+ function done() {
220
+ signal.removeEventListener("abort", aborted);
221
+ resolve();
222
+ }
223
+ function aborted() {
224
+ clearTimeout(timer);
225
+ signal.removeEventListener("abort", aborted);
226
+ reject(signal.reason ?? new Error("Cursor retry aborted"));
227
+ }
228
+ signal.addEventListener("abort", aborted, { once: true });
229
+ });
230
+ }
231
+ //#endregion
232
+
233
+ //#region fallback models (used when GetUsableModels is unreachable)
234
+ const FALLBACK_MODELS = Object.freeze([
235
+ { id: "composer-2", name: "Composer 2", contextWindow: 200000 },
236
+ { id: "claude-4-sonnet", name: "Claude 4 Sonnet", contextWindow: 200000 },
237
+ { id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet", contextWindow: 200000 },
238
+ { id: "claude-sonnet-4", name: "Claude Sonnet 4", contextWindow: 200000 },
239
+ { id: "gpt-4o", name: "GPT-4o", contextWindow: 128000 },
240
+ { id: "gpt-4.1", name: "GPT-4.1", contextWindow: 1000000 },
241
+ { id: "o3", name: "o3", contextWindow: 200000 },
242
+ { id: "o4-mini", name: "o4-mini", contextWindow: 200000 },
243
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextWindow: 1000000 },
244
+ { id: "cursor-small", name: "Cursor Small", contextWindow: 200000 },
245
+ ]);
246
+ //#endregion
247
+
248
+ //#region credential store
249
+ const clone = (value) => (value === undefined ? undefined : structuredClone(value));
250
+
251
+ function assertOAuthCredential(value) {
252
+ if (value === undefined) return undefined;
253
+ if (
254
+ value === null ||
255
+ typeof value !== "object" ||
256
+ value.type !== "oauth" ||
257
+ typeof value.access !== "string" ||
258
+ value.access.length === 0 ||
259
+ typeof value.refresh !== "string" ||
260
+ value.refresh.length === 0 ||
261
+ typeof value.expires !== "number" ||
262
+ !Number.isFinite(value.expires)
263
+ ) {
264
+ throw new Error("Cursor credential store received a malformed OAuth credential");
265
+ }
266
+ return clone(value);
267
+ }
268
+
269
+ function parseOAuthCredential(raw) {
270
+ try {
271
+ return assertOAuthCredential(JSON.parse(raw));
272
+ } catch (error) {
273
+ if (error?.message === "Cursor credential store received a malformed OAuth credential") throw error;
274
+ throw new Error("Cursor credential store contains malformed OAuth JSON", { cause: error });
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Adapt DSH's managed string credential service to Cursor's typed OAuth
280
+ * credential. Write operations are serialized so an older refresh response
281
+ * cannot overwrite a newer rotated token.
282
+ */
283
+ export class CursorCredentialStore {
284
+ #chain = Promise.resolve();
285
+
286
+ constructor(credentials, ref) {
287
+ if (credentials === undefined || credentials === null) {
288
+ throw new Error("Cursor OAuth requires the DSH credentials service");
289
+ }
290
+ this.credentials = credentials;
291
+ this.ref = ref;
292
+ }
293
+
294
+ #enqueue(operation) {
295
+ const current = this.#chain.catch(() => undefined).then(operation);
296
+ const tail = current.catch(() => undefined);
297
+ this.#chain = tail;
298
+ return current;
299
+ }
300
+
301
+ async read() {
302
+ const hit = await this.credentials.resolve(this.ref);
303
+ if (hit?.value === undefined || hit.value === "") return undefined;
304
+ return parseOAuthCredential(hit.value);
305
+ }
306
+
307
+ async write(credential) {
308
+ const validated = assertOAuthCredential(credential);
309
+ if (validated === undefined) throw new Error("Cursor credential store cannot write an empty credential");
310
+ await this.credentials.set(this.ref, JSON.stringify(validated));
311
+ return clone(validated);
312
+ }
313
+
314
+ modify(update) {
315
+ return this.#enqueue(async () => {
316
+ const current = await this.read();
317
+ const next = await update(clone(current));
318
+ if (next === undefined) return current;
319
+ return this.write(next);
320
+ });
321
+ }
322
+
323
+ async clear() {
324
+ await this.#enqueue(async () => {
325
+ await this.credentials.unset(this.ref);
326
+ });
327
+ }
328
+ }
329
+ //#endregion
330
+
331
+ //#region token helpers
332
+ /**
333
+ * Extract a JWT expiry (ms epoch) with a safety margin.
334
+ * Falls back to a default lifetime when the token cannot be parsed.
335
+ */
336
+ export function getTokenExpiry(token, now = Date.now) {
337
+ try {
338
+ const parts = token.split(".");
339
+ if (parts.length !== 3 || !parts[1]) return now() + DEFAULT_TOKEN_LIFETIME_MS;
340
+ const decoded = JSON.parse(
341
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
342
+ );
343
+ if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") {
344
+ return decoded.exp * 1000;
345
+ }
346
+ } catch {}
347
+ return now() + DEFAULT_TOKEN_LIFETIME_MS;
348
+ }
349
+
350
+ /**
351
+ * Decode the `sub` claim of an access token and strip the identity-provider
352
+ * prefix (e.g. `github|user_...` → `user_...`), which is what the dashboard
353
+ * session cookie and the `/api/usage?user=` parameter expect.
354
+ */
355
+ export function getTokenSub(token) {
356
+ try {
357
+ const parts = token.split(".");
358
+ if (parts.length !== 3 || !parts[1]) return undefined;
359
+ const decoded = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
360
+ if (typeof decoded?.sub !== "string" || decoded.sub.length === 0) return undefined;
361
+ return decoded.sub.includes("|") ? decoded.sub.split("|").pop() : decoded.sub;
362
+ } catch {
363
+ return undefined;
364
+ }
365
+ }
366
+ //#endregion
367
+
368
+ //#region pkce
369
+ async function generatePkce() {
370
+ const verifierBytes = new Uint8Array(96);
371
+ globalThis.crypto.getRandomValues(verifierBytes);
372
+ const verifier = Buffer.from(verifierBytes).toString("base64url");
373
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
374
+ const challenge = Buffer.from(digest).toString("base64url");
375
+ return { verifier, challenge };
376
+ }
377
+
378
+ /** Build the browser login URL for the PKCE flow. */
379
+ export function buildLoginUrl({ challenge, uuid }) {
380
+ const params = new URLSearchParams({ challenge, uuid, mode: "login", redirectTarget: "cli" });
381
+ return `${CURSOR_LOGIN_URL}?${params.toString()}`;
382
+ }
383
+ //#endregion
384
+
385
+ //#region external url
386
+ /** Validate the only external origin this plugin may launch. */
387
+ export function assertCursorAuthUrl(value) {
388
+ let url;
389
+ try {
390
+ url = new URL(value);
391
+ } catch {
392
+ throw new Error("Cursor auth URL is invalid");
393
+ }
394
+ if (url.protocol !== "https:") throw new Error("Cursor auth URL must use HTTPS");
395
+ if (url.origin !== CURSOR_AUTH_ORIGIN || url.username !== "" || url.password !== "") {
396
+ throw new Error("Cursor auth URL must use the cursor.com origin");
397
+ }
398
+ return url.href;
399
+ }
400
+
401
+ /** Return a shell-free native opener command for the current desktop. */
402
+ export function commandForCursorAuthUrl(value, platform = process.platform) {
403
+ const url = assertCursorAuthUrl(value);
404
+ if (platform === "win32") return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url], shell: false };
405
+ if (platform === "darwin") return { file: "open", args: [url], shell: false };
406
+ if (platform === "linux") return { file: "xdg-open", args: [url], shell: false };
407
+ throw new Error(`Cursor auth URL opener is unsupported on ${platform}`);
408
+ }
409
+
410
+ export function openCursorAuthUrl(value, options = {}) {
411
+ const command = commandForCursorAuthUrl(value, options.platform);
412
+ const spawnProcess = options.spawn ?? spawn;
413
+ return new Promise((resolve, reject) => {
414
+ const child = spawnProcess(command.file, command.args, {
415
+ detached: true,
416
+ stdio: "ignore",
417
+ windowsHide: true,
418
+ shell: command.shell,
419
+ });
420
+ child.once("error", reject);
421
+ child.once("spawn", () => {
422
+ child.unref();
423
+ resolve();
424
+ });
425
+ });
426
+ }
427
+ //#endregion
428
+
429
+ //#region auth service
430
+ /**
431
+ * Owns the Cursor OAuth lifecycle: PKCE login polling, refresh, and status.
432
+ * Tokens live in the DSH credential store; the browser client only ever sees
433
+ * `{ authenticated, provider, type, expiresAt }`.
434
+ */
435
+ export class CursorAuthService {
436
+ constructor(store, options = {}) {
437
+ this.store = store;
438
+ this.fetch = options.fetch ?? nativeFetch;
439
+ this.now = options.now ?? Date.now;
440
+ this.logger = options.logger;
441
+ }
442
+
443
+ #log(level, message, ...args) {
444
+ try {
445
+ this.logger?.[level]?.(`cursor-subscription: ${message}`, ...args);
446
+ } catch {}
447
+ }
448
+
449
+ async status({ signal } = {}) {
450
+ signal?.throwIfAborted();
451
+ const current = await this.store.read();
452
+ if (current === undefined) return { authenticated: false, provider: PROVIDER };
453
+ return {
454
+ authenticated: true,
455
+ provider: PROVIDER,
456
+ type: "oauth",
457
+ expiresAt: current.expires,
458
+ };
459
+ }
460
+
461
+ /**
462
+ * Resolve a usable access token, refreshing first when the stored token is
463
+ * missing, expired, or about to expire.
464
+ * @returns {Promise<string>} the bearer token.
465
+ */
466
+ async accessToken({ signal } = {}) {
467
+ const credential = await this.credential({ signal });
468
+ return credential.access;
469
+ }
470
+
471
+ /**
472
+ * Resolve the stored credential, refreshing first when its token is
473
+ * missing, expired, or about to expire.
474
+ * @returns {Promise<{access: string, refresh: string, expires: number}>}
475
+ */
476
+ async credential({ signal } = {}) {
477
+ signal?.throwIfAborted();
478
+ const current = await this.store.read();
479
+ if (current === undefined) {
480
+ throw new LlmError("Cursor subscription is not signed in", "MISSING_CREDENTIAL");
481
+ }
482
+ if (current.expires - this.now() > REFRESH_AHEAD_MS) return current;
483
+ const refreshed = await this.refresh(current, { signal });
484
+ return refreshed;
485
+ }
486
+
487
+ async refresh(current, { signal } = {}) {
488
+ const response = await this.fetch(`${CURSOR_BASE_URL}${CURSOR_REFRESH_PATH}`, {
489
+ method: "POST",
490
+ redirect: "error",
491
+ headers: {
492
+ authorization: `Bearer ${current.refresh}`,
493
+ "content-type": "application/json",
494
+ accept: "application/json",
495
+ "user-agent": "dsh-cursor-subscription/0.1.0",
496
+ },
497
+ body: "{}",
498
+ signal,
499
+ });
500
+ if (!response.ok) {
501
+ this.#log("warn", "token refresh failed (HTTP %s)", response.status);
502
+ if (response.status === 401 || response.status === 403) {
503
+ throw new LlmError("Cursor sign-in needs to be renewed", "INVALID_CREDENTIAL");
504
+ }
505
+ throw new LlmError(`Cursor token refresh failed (HTTP ${response.status})`, "AUTH_FAILED");
506
+ }
507
+ let data;
508
+ try {
509
+ data = await response.json();
510
+ } catch (error) {
511
+ throw new LlmError("Cursor returned an unreadable token response", "AUTH_FAILED", { cause: error });
512
+ }
513
+ if (typeof data?.accessToken !== "string" || data.accessToken.length === 0) {
514
+ throw new LlmError("Cursor sign-in needs to be renewed", "INVALID_CREDENTIAL");
515
+ }
516
+ const next = {
517
+ type: "oauth",
518
+ access: data.accessToken,
519
+ refresh: typeof data.refreshToken === "string" && data.refreshToken.length > 0 ? data.refreshToken : current.refresh,
520
+ expires: getTokenExpiry(data.accessToken, this.now),
521
+ };
522
+ const stored = await this.store.modify((latest) => {
523
+ // Only rotate when the stored credential is still the one we refreshed.
524
+ if (latest === undefined || latest.refresh !== current.refresh) return latest;
525
+ return next;
526
+ });
527
+ return stored ?? next;
528
+ }
529
+
530
+ /** Start a login and resolve once the browser flow completes. */
531
+ async login({ interaction, signal }) {
532
+ signal?.throwIfAborted();
533
+ const { verifier, challenge } = await generatePkce();
534
+ const uuid = randomUUID();
535
+ const loginUrl = buildLoginUrl({ challenge, uuid });
536
+
537
+ interaction.notify?.({
538
+ type: "auth_url",
539
+ url: assertCursorAuthUrl(loginUrl),
540
+ instructions: "在浏览器中登录 Cursor 并授权后,此页面会自动完成登录。",
541
+ });
542
+ interaction.prompt?.({ type: "text", message: "等待浏览器登录完成…" }).catch(() => {});
543
+ interaction.signal?.throwIfAborted();
544
+
545
+ let delay = 1000;
546
+ for (let attempt = 0; attempt < 150; attempt++) {
547
+ await sleep(delay);
548
+ signal?.throwIfAborted();
549
+ interaction.signal?.throwIfAborted();
550
+ let response;
551
+ try {
552
+ response = await this.fetch(`${CURSOR_BASE_URL}${CURSOR_POLL_PATH}?uuid=${encodeURIComponent(uuid)}&verifier=${encodeURIComponent(verifier)}`, {
553
+ redirect: "error",
554
+ headers: { accept: "application/json", "user-agent": "dsh-cursor-subscription/0.1.0" },
555
+ signal,
556
+ });
557
+ } catch (error) {
558
+ this.#log("warn", "login poll request failed on attempt %d: %s", attempt, error?.cause?.code ?? error?.message);
559
+ throw new LlmError("Cursor login poll request failed", "AUTH_FAILED", { cause: error });
560
+ }
561
+ if (response.status === 404) {
562
+ delay = Math.min(delay * 1.2, 10000);
563
+ continue;
564
+ }
565
+ if (!response.ok) {
566
+ this.#log("warn", "login poll returned HTTP %s on attempt %d", response.status, attempt);
567
+ throw new LlmError(`Cursor login poll failed (HTTP ${response.status})`, "AUTH_FAILED");
568
+ }
569
+ const text = await Promise.resolve(response.text()).catch(() => "");
570
+ this.#log("info", "login poll succeeded after %d attempts", attempt);
571
+ let data;
572
+ try {
573
+ data = JSON.parse(text);
574
+ } catch (error) {
575
+ this.#log("warn", "login poll returned unreadable JSON: %s", text.slice(0, 200));
576
+ throw new LlmError("Cursor login returned an unreadable response", "AUTH_FAILED", { cause: error });
577
+ }
578
+ if (typeof data?.accessToken !== "string" || data.accessToken.length === 0) {
579
+ this.#log("warn", "login poll response had no accessToken");
580
+ throw new LlmError("Cursor login returned no access token", "AUTH_FAILED");
581
+ }
582
+ const credential = {
583
+ type: "oauth",
584
+ access: data.accessToken,
585
+ refresh: typeof data.refreshToken === "string" && data.refreshToken.length > 0 ? data.refreshToken : "",
586
+ expires: getTokenExpiry(data.accessToken, this.now),
587
+ };
588
+ try {
589
+ await this.store.modify(() => credential);
590
+ } catch (error) {
591
+ this.#log("error", "failed to store Cursor credential: %s", error?.message);
592
+ throw new LlmError("Cursor login could not store the credential", "AUTH_FAILED", { cause: error });
593
+ }
594
+ return;
595
+ }
596
+ this.#log("warn", "login timed out after 150 poll attempts");
597
+ throw new LlmError("Cursor login timed out", "AUTH_FAILED");
598
+ }
599
+
600
+ async logout({ signal } = {}) {
601
+ signal?.throwIfAborted();
602
+ await this.store.clear();
603
+ return this.status({ signal });
604
+ }
605
+ }
606
+
607
+ function sleep(ms) {
608
+ return new Promise((resolve) => setTimeout(resolve, ms));
609
+ }
610
+ //#endregion
611
+
612
+ //#region login coordinator
613
+ const TERMINAL_PHASES = new Set(["authenticated", "failed", "cancelled"]);
614
+ const publicClone = (value) => structuredClone(value);
615
+ const asObject = (value) => (value !== null && typeof value === "object" ? value : {});
616
+ const ok = (value) => ({ ok: true, value });
617
+ const badRequest = (message) => ({ ok: false, error: { code: "bad-request", message, details: { issues: [] } } });
618
+
619
+ const deferred = () => {
620
+ let resolve;
621
+ let reject;
622
+ return {
623
+ promise: new Promise((onResolve, onReject) => {
624
+ resolve = onResolve;
625
+ reject = onReject;
626
+ }),
627
+ resolve,
628
+ reject,
629
+ };
630
+ };
631
+
632
+ /** Own one host-side login without exposing tokens to the browser client. */
633
+ export class CursorLoginCoordinator {
634
+ #sessions = new Map();
635
+ #activeId;
636
+
637
+ constructor(auth, options = {}) {
638
+ this.auth = auth;
639
+ this.createId = options.createId ?? (() => randomUUID());
640
+ this.logger = options.logger;
641
+ }
642
+
643
+ #log(level, message, ...args) {
644
+ try {
645
+ this.logger?.[level]?.(`cursor-subscription: ${message}`, ...args);
646
+ } catch {}
647
+ }
648
+
649
+ async accountStatus(options) {
650
+ return publicClone(await this.auth.status(options));
651
+ }
652
+
653
+ async start() {
654
+ const active = this.#activeId === undefined ? undefined : this.#sessions.get(this.#activeId);
655
+ if (active !== undefined && !TERMINAL_PHASES.has(active.view.phase)) {
656
+ throw new Error("a Cursor login is already active");
657
+ }
658
+ if (active !== undefined) this.#sessions.delete(active.view.id);
659
+ const id = this.createId();
660
+ const ready = deferred();
661
+ const controller = new AbortController();
662
+ const session = {
663
+ controller,
664
+ ready,
665
+ view: { id, provider: PROVIDER, method: "browser", phase: "starting", authenticated: false },
666
+ };
667
+ this.#sessions.set(id, session);
668
+ this.#activeId = id;
669
+ const publishReady = () => ready.resolve(this.read(id));
670
+ const interaction = {
671
+ signal: controller.signal,
672
+ prompt: async (prompt) => {
673
+ controller.signal.throwIfAborted();
674
+ const answer = deferred();
675
+ session.prompt = answer;
676
+ session.view = { ...session.view, phase: "waiting_input", prompt: publicPrompt(prompt) };
677
+ const abortPrompt = () => answer.reject(controller.signal.reason ?? new Error("login cancelled"));
678
+ controller.signal.addEventListener("abort", abortPrompt, { once: true });
679
+ prompt.signal?.addEventListener("abort", abortPrompt, { once: true });
680
+ publishReady();
681
+ try {
682
+ return await answer.promise;
683
+ } finally {
684
+ controller.signal.removeEventListener("abort", abortPrompt);
685
+ prompt.signal?.removeEventListener("abort", abortPrompt);
686
+ if (session.prompt === answer) session.prompt = undefined;
687
+ }
688
+ },
689
+ notify: (event) => {
690
+ if (controller.signal.aborted) return;
691
+ if (event.type === "auth_url") {
692
+ session.view = {
693
+ ...session.view,
694
+ phase: "waiting_browser",
695
+ authUrl: assertCursorAuthUrl(event.url),
696
+ ...typeof event.instructions === "string" ? { instructions: event.instructions } : {},
697
+ };
698
+ } else {
699
+ session.view = { ...session.view, message: String(event.message ?? "") };
700
+ }
701
+ publishReady();
702
+ },
703
+ };
704
+ session.run = Promise.resolve()
705
+ .then(() => this.auth.login({ interaction, signal: controller.signal }))
706
+ .then(async () => {
707
+ if (controller.signal.aborted) return;
708
+ const status = await this.auth.status();
709
+ session.view = {
710
+ id,
711
+ provider: PROVIDER,
712
+ method: "browser",
713
+ phase: "authenticated",
714
+ authenticated: status.authenticated === true,
715
+ ...typeof status.expiresAt === "number" ? { expiresAt: status.expiresAt } : {},
716
+ };
717
+ })
718
+ .catch((error) => {
719
+ if (controller.signal.aborted) {
720
+ session.view = { id, provider: PROVIDER, method: "browser", phase: "cancelled", authenticated: false };
721
+ return;
722
+ }
723
+ const message = error instanceof Error ? error.message : String(error);
724
+ this.#log("warn", "login failed: %s", message);
725
+ session.view = {
726
+ id,
727
+ provider: PROVIDER,
728
+ method: "browser",
729
+ phase: "failed",
730
+ authenticated: false,
731
+ error: "Cursor login failed",
732
+ ...(message ? { detail: message.slice(0, 500) } : {}),
733
+ };
734
+ session.hostError = error;
735
+ })
736
+ .finally(publishReady);
737
+ return ready.promise;
738
+ }
739
+
740
+ read(id) {
741
+ const session = this.#sessions.get(id);
742
+ if (session === undefined) throw new Error("unknown Cursor login");
743
+ return publicClone(session.view);
744
+ }
745
+
746
+ async cancel(id) {
747
+ const session = this.#sessions.get(id);
748
+ if (session === undefined) throw new Error("unknown Cursor login");
749
+ if (!TERMINAL_PHASES.has(session.view.phase)) {
750
+ session.view = { id, provider: PROVIDER, method: "browser", phase: "cancelled", authenticated: false };
751
+ session.controller.abort(new Error("Cursor login cancelled"));
752
+ }
753
+ await Promise.resolve(session.run).catch(() => undefined);
754
+ return this.read(id);
755
+ }
756
+
757
+ async logout(options) {
758
+ if (this.#activeId !== undefined) {
759
+ const active = this.#sessions.get(this.#activeId);
760
+ if (active !== undefined && !TERMINAL_PHASES.has(active.view.phase)) await this.cancel(active.view.id);
761
+ }
762
+ await this.auth.logout(options);
763
+ return this.accountStatus(options);
764
+ }
765
+ }
766
+
767
+ const publicPrompt = (prompt) => ({
768
+ type: prompt.type,
769
+ message: String(prompt.message ?? ""),
770
+ ...typeof prompt.placeholder === "string" ? { placeholder: prompt.placeholder } : {},
771
+ });
772
+
773
+ /** Map the loopback-only DSH Connection channel onto the coordinator. */
774
+ export function createCursorRpcHandler(coordinator, options = {}) {
775
+ const openExternal = options.openExternal;
776
+ const usageReader = options.usageReader;
777
+ const modelsProvider = options.modelsProvider;
778
+ const settingsController = options.settings;
779
+ const publicError = (code, message) => ({ ok: false, error: { code, message, details: { issues: [] } } });
780
+ return async (endpoint, payload, signal) => {
781
+ try {
782
+ signal.throwIfAborted();
783
+ const input = asObject(payload);
784
+ if (endpoint === "settings") return ok(settingsController.read());
785
+ if (endpoint === "settings/update") {
786
+ if (!Number.isSafeInteger(input.revision) || input.revision < 0) throw new Error("invalid Cursor settings revision");
787
+ const patch = {};
788
+ for (const key of ["maxToolRounds", "retryCount", "retryIntervalMs", "retryHttpStatusCodes"]) {
789
+ if (Object.hasOwn(input, key)) patch[key] = input[key];
790
+ }
791
+ return ok(await settingsController.update(patch, input.revision));
792
+ }
793
+ if (endpoint === "status") return ok(await coordinator.accountStatus({ signal }));
794
+ if (endpoint === "usage") {
795
+ try {
796
+ return ok(await usageReader.read({ force: input.force === true, signal }));
797
+ } catch (error) {
798
+ const known = new Set(["Cursor subscription is not signed in", "Cursor sign-in needs to be renewed"]);
799
+ if (error instanceof Error && known.has(error.message)) return publicError("internal", error.message);
800
+ const raw = error instanceof Error ? error.message : "";
801
+ const http = raw.match(/^HTTP (\d{3})\b/);
802
+ const message = http ? `Could not read Cursor usage (HTTP ${http[1]})` : `Could not read Cursor usage: ${raw}`;
803
+ return publicError("internal", message);
804
+ }
805
+ }
806
+ if (endpoint === "models") {
807
+ try {
808
+ return ok({
809
+ models: await modelsProvider.listModelsForRpc({ force: input.force === true, signal }),
810
+ fetchedAt: Date.now(),
811
+ });
812
+ } catch (error) {
813
+ return publicError("internal", error instanceof Error ? error.message : "Could not list Cursor models");
814
+ }
815
+ }
816
+ if (endpoint === "login/start") {
817
+ const started = await coordinator.start();
818
+ if (input.openExternal !== true) return ok(started);
819
+ const url = started.authUrl;
820
+ if (typeof url !== "string" || openExternal === undefined) return ok({ ...started, externalOpened: false });
821
+ try {
822
+ await openExternal(url);
823
+ return ok({ ...started, externalOpened: true });
824
+ } catch {
825
+ return ok({ ...started, externalOpened: false });
826
+ }
827
+ }
828
+ if (endpoint === "login/status") return ok(coordinator.read(input.id));
829
+ if (endpoint === "login/cancel") return ok(await coordinator.cancel(input.id));
830
+ if (endpoint === "logout") {
831
+ const result = await coordinator.logout({ signal });
832
+ usageReader?.clear();
833
+ return ok(result);
834
+ }
835
+ return badRequest(`unknown Cursor auth endpoint: ${endpoint}`);
836
+ } catch (error) {
837
+ if (signal.aborted) throw error;
838
+ const message = error instanceof Error && /^(unknown|unsupported|a Cursor|Cursor login|Cursor auth URL)/.test(error.message)
839
+ ? error.message
840
+ : "Cursor request failed";
841
+ return badRequest(message);
842
+ }
843
+ };
844
+ }
845
+ //#endregion
846
+
847
+ //#region protobuf message builders (agent.v1 subset)
848
+ const textEncoder = new TextEncoder();
849
+
850
+ function bytesOf(value) {
851
+ return value instanceof Uint8Array ? value : new Uint8Array(value);
852
+ }
853
+
854
+ function encodeUserMessage({ text, messageId }) {
855
+ const writer = new Writer();
856
+ if (typeof text === "string" && text.length > 0) writer.string(1, text); // text
857
+ if (typeof messageId === "string" && messageId.length > 0) writer.string(2, messageId); // message_id
858
+ return writer.finish();
859
+ }
860
+
861
+ function encodeAssistantMessage(text) {
862
+ return new Writer().string(1, text).finish(); // text
863
+ }
864
+
865
+ /** ConversationStep { assistant_message = 1 } */
866
+ function encodeAssistantStep(text) {
867
+ const inner = encodeAssistantMessage(text);
868
+ return new Writer().message(1, inner).finish();
869
+ }
870
+
871
+ /** AgentConversationTurnStructure { user_message = 1, steps = 2 } */
872
+ function encodeAgentTurn(userBytes, stepBytes) {
873
+ const writer = new Writer();
874
+ if (userBytes.length > 0) writer.bytes(1, userBytes);
875
+ for (const step of stepBytes) writer.bytes(2, step);
876
+ return writer.finish();
877
+ }
878
+
879
+ /** ConversationTurnStructure { agent_conversation_turn = 1 } */
880
+ function encodeTurnStructure(turnBytes) {
881
+ return new Writer().message(1, turnBytes).finish();
882
+ }
883
+
884
+ /** ModelDetails { model_id = 1, display_model_id = 3, display_name = 4 } */
885
+ function encodeModelDetails(modelId) {
886
+ const writer = new Writer();
887
+ writer.string(1, modelId);
888
+ writer.string(3, modelId);
889
+ writer.string(4, modelId);
890
+ return writer.finish();
891
+ }
892
+
893
+ /** ConversationAction { user_message_action = 1 } → UserMessageAction { user_message = 1 } */
894
+ function encodeUserMessageAction(userBytes) {
895
+ const inner = new Writer().message(1, userBytes).finish(); // UserMessageAction
896
+ return new Writer().message(1, inner).finish(); // ConversationAction
897
+ }
898
+
899
+ /** ConversationStateStructure — the durable conversation payload. */
900
+ function encodeConversationState({ rootPromptBlobIds, turns }) {
901
+ const writer = new Writer();
902
+ for (const id of rootPromptBlobIds) writer.bytes(1, id); // root_prompt_messages_json
903
+ for (const turn of turns) writer.bytes(8, turn); // turns
904
+ return writer.finish();
905
+ }
906
+
907
+ /** AgentRunRequest { conversation_state=1, action=2, model_details=3, conversation_id=5 } */
908
+ function encodeRunRequest({ conversationState, action, modelDetails, conversationId }) {
909
+ const writer = new Writer();
910
+ writer.message(1, conversationState);
911
+ writer.message(2, action);
912
+ writer.message(3, modelDetails);
913
+ if (conversationId) writer.string(5, conversationId);
914
+ return writer.finish();
915
+ }
916
+
917
+ /** AgentClientMessage { run_request = 1 } */
918
+ function encodeRunMessage(runRequestBytes) {
919
+ return new Writer().message(1, runRequestBytes).finish();
920
+ }
921
+
922
+ /** AgentClientMessage { client_heartbeat = 7 } */
923
+ function encodeHeartbeat() {
924
+ return new Writer().message(7, new Uint8Array(0)).finish();
925
+ }
926
+
927
+ /** KvClientMessage { id=1, get_blob_result=2 { blob_data=1 } } */
928
+ export function encodeGetBlobResult(id, blobData) {
929
+ const inner = blobData === undefined ? new Uint8Array(0) : blobData;
930
+ const result = new Writer().bytes(1, inner).finish(); // GetBlobResult
931
+ const writer = new Writer();
932
+ writer.varint(1, id);
933
+ writer.message(2, result);
934
+ return writer.finish();
935
+ }
936
+
937
+ /** AgentClientMessage { kv_client_message = 3 } */
938
+ export function encodeKvClientMessage(kvBytes) {
939
+ return new Writer().message(3, kvBytes).finish();
940
+ }
941
+
942
+ /** KvClientMessage { id=1, set_blob_result=3 } — acknowledge a server blob write. */
943
+ export function encodeSetBlobResult(id) {
944
+ const writer = new Writer();
945
+ writer.varint(1, id);
946
+ writer.message(3, new Uint8Array(0)); // set_blob_result (empty success)
947
+ return writer.finish();
948
+ }
949
+
950
+ /** McpResult.success with one text content item. */
951
+ export function encodeMcpResult({ content, isError = false }) {
952
+ return encodeMcpResultSuccess(String(content ?? ""), isError);
953
+ }
954
+
955
+ /** ExecClientMessage { id=1, exec_id=15, message=... } */
956
+ export function encodeExecClientMessage(id, execId, messageField, messageBytes) {
957
+ const writer = new Writer();
958
+ writer.varint(1, id);
959
+ if (typeof execId === "string" && execId.length > 0) writer.string(15, execId);
960
+ if (messageBytes !== undefined) writer.message(messageField, messageBytes);
961
+ return writer.finish();
962
+ }
963
+
964
+ /** AgentClientMessage { exec_client_message = 2 } */
965
+ export function encodeExecClientMessageEnvelope(execBytes) {
966
+ return new Writer().message(2, execBytes).finish();
967
+ }
968
+
969
+ /** RequestContextResult { success=1 { request_context=1 } } with tools. */
970
+ export function encodeRequestContextResult(tools) {
971
+ const context = new Writer();
972
+ for (const tool of tools) context.message(7, tool); // RequestContext.tools
973
+ const success = new Writer().message(1, context.finish()).finish(); // RequestContextSuccess
974
+ return new Writer().message(1, success).finish(); // RequestContextResult
975
+ }
976
+
977
+ /** McpToolDefinition { name=1, description=2, input_schema=3, provider_identifier=4, tool_name=5 } */
978
+ export function encodeMcpToolDefinition({ name, description, inputSchema, providerIdentifier, toolName }) {
979
+ const writer = new Writer();
980
+ writer.string(1, name);
981
+ writer.string(2, description ?? "");
982
+ writer.bytes(3, bytesOf(inputSchema));
983
+ writer.string(4, providerIdentifier ?? "dsh-cursor-subscription");
984
+ writer.string(5, toolName ?? name);
985
+ return writer.finish();
986
+ }
987
+
988
+ /** McpTextContent { text = 1 } */
989
+ function encodeMcpTextContent(text) {
990
+ return new Writer().string(1, text).finish();
991
+ }
992
+
993
+ /** McpToolResultContentItem { text = 1 } */
994
+ function encodeMcpToolResultContentItem(text) {
995
+ return new Writer().message(1, encodeMcpTextContent(text)).finish();
996
+ }
997
+
998
+ /** McpSuccess { content=1, is_error=2 } */
999
+ function encodeMcpSuccess(text, isError) {
1000
+ const writer = new Writer();
1001
+ writer.message(1, encodeMcpToolResultContentItem(text));
1002
+ writer.varint(2, isError ? 1 : 0);
1003
+ return writer.finish();
1004
+ }
1005
+
1006
+ /** McpResult { success=1 | error=2 } */
1007
+ function encodeMcpResultSuccess(text, isError) {
1008
+ return new Writer().message(1, encodeMcpSuccess(text, isError)).finish();
1009
+ }
1010
+
1011
+ function encodeMcpError(error) {
1012
+ return new Writer().message(2, new Writer().string(1, error).finish()).finish();
1013
+ }
1014
+
1015
+ /** ReadResult { rejected=3 { path=1, reason=2 } } */
1016
+ function encodeReadRejected(path, reason) {
1017
+ const rejected = new Writer().string(1, path ?? "").string(2, reason).finish();
1018
+ return new Writer().message(3, rejected).finish();
1019
+ }
1020
+
1021
+ /** LsResult { rejected=3 { path=1, reason=2 } } */
1022
+ function encodeLsRejected(path, reason) {
1023
+ const rejected = new Writer().string(1, path ?? "").string(2, reason).finish();
1024
+ return new Writer().message(3, rejected).finish();
1025
+ }
1026
+
1027
+ /** GrepResult { error=2 { error=1 } } */
1028
+ function encodeGrepError(error) {
1029
+ return new Writer().message(2, new Writer().string(1, error).finish()).finish();
1030
+ }
1031
+
1032
+ /** WriteResult { rejected = 6 } (oneof: success=1, permission_denied=3, no_space=4, error=5, rejected=6) */
1033
+ function encodeWriteRejected(path, reason) {
1034
+ const rejected = new Writer().string(1, path ?? "").string(2, reason).finish();
1035
+ return new Writer().message(6, rejected).finish();
1036
+ }
1037
+
1038
+ /** DeleteResult { rejected = 6 } (oneof: success=1, file_not_found=2, not_file=3, permission_denied=4, file_busy=5, rejected=6) */
1039
+ function encodeDeleteRejected(path, reason) {
1040
+ const rejected = new Writer().string(1, path ?? "").string(2, reason).finish();
1041
+ return new Writer().message(6, rejected).finish();
1042
+ }
1043
+
1044
+ /** ShellRejected { command=1, working_directory=2, reason=3, is_readonly=4 } */
1045
+ function encodeShellRejected(command, workingDirectory, reason) {
1046
+ const writer = new Writer();
1047
+ writer.string(1, command ?? "");
1048
+ writer.string(2, workingDirectory ?? "");
1049
+ writer.string(3, reason);
1050
+ writer.varint(4, 0);
1051
+ return writer.finish();
1052
+ }
1053
+
1054
+ /** ShellResult { rejected = 4 } (oneof: success=1, failure=2, timeout=3, rejected=4) */
1055
+ function encodeShellRejectedResult(command, workingDirectory, reason) {
1056
+ return new Writer().message(4, encodeShellRejected(command, workingDirectory, reason)).finish();
1057
+ }
1058
+
1059
+ /**
1060
+ * ShellStream { rejected = 5 } — the reply type for `shellStreamArgs` execs
1061
+ * (streaming shell), carried by ExecClientMessage.shell_stream (field 14).
1062
+ */
1063
+ function encodeShellStreamRejected(command, workingDirectory, reason) {
1064
+ return new Writer().message(5, encodeShellRejected(command, workingDirectory, reason)).finish();
1065
+ }
1066
+
1067
+ /** BackgroundShellSpawnResult { rejected = 3 } (oneof: success=1, error=2, rejected=3, permission_denied=4) */
1068
+ function encodeBackgroundShellRejectedResult(command, workingDirectory, reason) {
1069
+ return new Writer().message(3, encodeShellRejected(command, workingDirectory, reason)).finish();
1070
+ }
1071
+
1072
+ /** FetchResult { error=2 { url=1, error=2 } } */
1073
+ function encodeFetchError(url, error) {
1074
+ const inner = new Writer().string(1, url ?? "").string(2, error).finish();
1075
+ return new Writer().message(2, inner).finish();
1076
+ }
1077
+
1078
+ /** WriteShellStdinResult { error=2 { error=1 } } */
1079
+ function encodeWriteShellStdinError(error) {
1080
+ return new Writer().message(2, new Writer().string(1, error).finish()).finish();
1081
+ }
1082
+
1083
+ /** DiagnosticsResult { success = 1 } — empty success keeps the exec well-formed. */
1084
+ function encodeDiagnosticsResult() {
1085
+ return new Writer().message(1, new Uint8Array(0)).finish();
1086
+ }
1087
+ //#endregion
1088
+
1089
+ //#region protobuf message parsers (agent.v1 subset)
1090
+ const decoder = new TextDecoder();
1091
+
1092
+ function readTag(reader) {
1093
+ if (reader.done) return undefined;
1094
+ const { field, wireType } = reader.tag();
1095
+ return { field, wireType };
1096
+ }
1097
+
1098
+ /** AgentServerMessage { interaction_update=1, exec_server_message=2, kv_server_message=4, ... } */
1099
+ export function decodeAgentServerMessage(bytes) {
1100
+ const reader = new Reader(bytes);
1101
+ while (!reader.done) {
1102
+ const { field, wireType } = reader.tag();
1103
+ if (wireType !== 2) {
1104
+ reader.skip(wireType);
1105
+ continue;
1106
+ }
1107
+ const payload = reader.bytes();
1108
+ if (field === 1) return { case: "interactionUpdate", value: decodeInteractionUpdate(payload) };
1109
+ if (field === 2) return { case: "execServerMessage", value: decodeExecServerMessage(payload) };
1110
+ if (field === 3) return { case: "conversationCheckpointUpdate", value: payload };
1111
+ if (field === 4) return { case: "kvServerMessage", value: decodeKvServerMessage(payload) };
1112
+ // 5 = exec_server_control, 7 = interaction_query (ignored)
1113
+ }
1114
+ return { case: "unknown", value: undefined };
1115
+ }
1116
+
1117
+ /** InteractionUpdate { text_delta=1, thinking_delta=4, token_delta=8, turn_ended=14, ... } */
1118
+ export function decodeInteractionUpdate(bytes) {
1119
+ const reader = new Reader(bytes);
1120
+ while (!reader.done) {
1121
+ const { field, wireType } = reader.tag();
1122
+ if (wireType !== 2) {
1123
+ reader.skip(wireType);
1124
+ continue;
1125
+ }
1126
+ const payload = reader.bytes();
1127
+ if (field === 1) return { type: "textDelta", text: decodeTextDelta(payload) }; // TextDeltaUpdate.text=1
1128
+ if (field === 4) return { type: "thinkingDelta", text: decodeTextDelta(payload) }; // ThinkingDeltaUpdate.text=1
1129
+ if (field === 8) return { type: "tokenDelta", tokens: decodeTokenDelta(payload) }; // TokenDeltaUpdate.tokens=1
1130
+ if (field === 14) return { type: "turnEnded" };
1131
+ if (field === 2) return { type: "toolCallStarted" };
1132
+ if (field === 3) return { type: "toolCallCompleted" };
1133
+ if (field === 7) return { type: "partialToolCall", ...decodePartialToolCall(payload) };
1134
+ if (field === 15) return { type: "toolCallDelta" };
1135
+ if (field === 13) return { type: "heartbeat" };
1136
+ // others (summary*, step_*, user_message_appended, shell_output_delta) ignored
1137
+ }
1138
+ return { type: "unknown" };
1139
+ }
1140
+
1141
+ function decodeTextDelta(bytes) {
1142
+ const reader = new Reader(bytes);
1143
+ while (!reader.done) {
1144
+ const { field, wireType } = reader.tag();
1145
+ if (field === 1 && wireType === 2) return reader.string();
1146
+ reader.skip(wireType);
1147
+ }
1148
+ return "";
1149
+ }
1150
+
1151
+ function decodeTokenDelta(bytes) {
1152
+ const reader = new Reader(bytes);
1153
+ while (!reader.done) {
1154
+ const { field, wireType } = reader.tag();
1155
+ if (field === 1 && wireType === 0) return reader.varint();
1156
+ reader.skip(wireType);
1157
+ }
1158
+ return 0;
1159
+ }
1160
+
1161
+ /** PartialToolCallUpdate { call_id=1, args_text_delta=3 } */
1162
+ function decodePartialToolCall(bytes) {
1163
+ const reader = new Reader(bytes);
1164
+ let callId = "";
1165
+ let argsTextDelta = "";
1166
+ while (!reader.done) {
1167
+ const { field, wireType } = reader.tag();
1168
+ if (field === 1 && wireType === 2) callId = reader.string();
1169
+ else if (field === 3 && wireType === 2) argsTextDelta = reader.string();
1170
+ else reader.skip(wireType);
1171
+ }
1172
+ return { callId, argsTextDelta };
1173
+ }
1174
+
1175
+ /** KvServerMessage { id=1, get_blob_args=2 { blob_id=1 } | set_blob_args=3 { blob_id=1, blob_data=2 } } */
1176
+ export function decodeKvServerMessage(bytes) {
1177
+ const reader = new Reader(bytes);
1178
+ let id = 0;
1179
+ let blobId;
1180
+ while (!reader.done) {
1181
+ const { field, wireType } = reader.tag();
1182
+ if (field === 1 && wireType === 0) id = reader.varint();
1183
+ else if (field === 2 && wireType === 2) blobId = decodeBlobId(reader.bytes());
1184
+ else if (field === 3 && wireType === 2) {
1185
+ return { id, case: "setBlobArgs", ...decodeSetBlobArgs(reader.bytes()) };
1186
+ } else reader.skip(wireType);
1187
+ }
1188
+ return blobId === undefined ? { id, case: "unknown" } : { id, case: "getBlobArgs", blobId };
1189
+ }
1190
+
1191
+ /** SetBlobArgs { blob_id=1, blob_data=2 } */
1192
+ function decodeSetBlobArgs(bytes) {
1193
+ const reader = new Reader(bytes);
1194
+ let blobId;
1195
+ let blobData;
1196
+ while (!reader.done) {
1197
+ const { field, wireType } = reader.tag();
1198
+ if (field === 1 && wireType === 2) blobId = reader.bytes();
1199
+ else if (field === 2 && wireType === 2) blobData = reader.bytes();
1200
+ else reader.skip(wireType);
1201
+ }
1202
+ return { blobId, blobData };
1203
+ }
1204
+
1205
+ function decodeBlobId(bytes) {
1206
+ const reader = new Reader(bytes);
1207
+ while (!reader.done) {
1208
+ const { field, wireType } = reader.tag();
1209
+ if (field === 1 && wireType === 2) return reader.bytes();
1210
+ reader.skip(wireType);
1211
+ }
1212
+ return undefined;
1213
+ }
1214
+
1215
+ /** ExecServerMessage { id=1, exec_id=15, request_context_args=10, mcp_args=11, ... } */
1216
+ export function decodeExecServerMessage(bytes) {
1217
+ const reader = new Reader(bytes);
1218
+ let id = 0;
1219
+ let execId = "";
1220
+ while (!reader.done) {
1221
+ const { field, wireType } = reader.tag();
1222
+ if (field === 1 && wireType === 0) {
1223
+ id = reader.varint();
1224
+ } else if (field === 15 && wireType === 2) {
1225
+ execId = reader.string();
1226
+ } else if (wireType === 2) {
1227
+ const payload = reader.bytes();
1228
+ if (field === 10) return { id, execId, case: "requestContextArgs" };
1229
+ if (field === 11) return { id, execId, case: "mcpArgs", args: decodeMcpArgs(payload) };
1230
+ if (field === 2) return { id, execId, case: "shellArgs", path: decodeSinglePathArg(payload) };
1231
+ if (field === 3) return { id, execId, case: "writeArgs", path: decodeSinglePathArg(payload) };
1232
+ if (field === 4) return { id, execId, case: "deleteArgs", path: decodeSinglePathArg(payload) };
1233
+ if (field === 5) return { id, execId, case: "grepArgs" };
1234
+ if (field === 7) return { id, execId, case: "readArgs", path: decodeReadArgsPath(payload) };
1235
+ if (field === 8) return { id, execId, case: "lsArgs", path: decodeSinglePathArg(payload) };
1236
+ if (field === 9) return { id, execId, case: "diagnosticsArgs" };
1237
+ if (field === 14) return { id, execId, case: "shellStreamArgs", path: decodeSinglePathArg(payload) };
1238
+ if (field === 16) return { id, execId, case: "backgroundShellSpawnArgs" };
1239
+ if (field === 17) return { id, execId, case: "listMcpResourcesExecArgs" };
1240
+ if (field === 18) return { id, execId, case: "readMcpResourceExecArgs" };
1241
+ if (field === 20) return { id, execId, case: "fetchArgs", url: decodeFetchUrl(payload) };
1242
+ if (field === 21) return { id, execId, case: "recordScreenArgs" };
1243
+ if (field === 22) return { id, execId, case: "computerUseArgs" };
1244
+ if (field === 23) return { id, execId, case: "writeShellStdinArgs" };
1245
+ } else {
1246
+ reader.skip(wireType);
1247
+ }
1248
+ }
1249
+ return { id, execId, case: "unknown" };
1250
+ }
1251
+
1252
+ function decodeSinglePathArg(bytes) {
1253
+ const reader = new Reader(bytes);
1254
+ while (!reader.done) {
1255
+ const { field, wireType } = reader.tag();
1256
+ if (field === 1 && wireType === 2) return reader.string();
1257
+ reader.skip(wireType);
1258
+ }
1259
+ return "";
1260
+ }
1261
+
1262
+ function decodeReadArgsPath(bytes) {
1263
+ return decodeSinglePathArg(bytes);
1264
+ }
1265
+
1266
+ function decodeFetchUrl(bytes) {
1267
+ const reader = new Reader(bytes);
1268
+ while (!reader.done) {
1269
+ const { field, wireType } = reader.tag();
1270
+ if (field === 1 && wireType === 2) return reader.string();
1271
+ reader.skip(wireType);
1272
+ }
1273
+ return "";
1274
+ }
1275
+
1276
+ /** McpArgs { name=1, args=2 (map<string,bytes>), tool_call_id=3, provider_identifier=4, tool_name=5 } */
1277
+ export function decodeMcpArgs(bytes) {
1278
+ const reader = new Reader(bytes);
1279
+ let name = "";
1280
+ let toolCallId = "";
1281
+ let providerIdentifier = "";
1282
+ let toolName = "";
1283
+ const args = {};
1284
+ while (!reader.done) {
1285
+ const { field, wireType } = reader.tag();
1286
+ if (field === 1 && wireType === 2) name = reader.string();
1287
+ else if (field === 2 && wireType === 2) {
1288
+ const entry = new Reader(reader.bytes());
1289
+ let key = "";
1290
+ let value;
1291
+ while (!entry.done) {
1292
+ const tag = entry.tag();
1293
+ if (tag.field === 1 && tag.wireType === 2) key = entry.string();
1294
+ else if (tag.field === 2 && tag.wireType === 2) value = entry.bytes();
1295
+ else entry.skip(tag.wireType);
1296
+ }
1297
+ if (key) args[key] = value;
1298
+ } else if (field === 3 && wireType === 2) toolCallId = reader.string();
1299
+ else if (field === 4 && wireType === 2) providerIdentifier = reader.string();
1300
+ else if (field === 5 && wireType === 2) toolName = reader.string();
1301
+ else reader.skip(wireType);
1302
+ }
1303
+ return { name, toolCallId, providerIdentifier, toolName, args };
1304
+ }
1305
+
1306
+ /** GetUsableModelsResponse { models = 1: repeated ModelDetails } */
1307
+ export function decodeUsableModels(bytes) {
1308
+ const reader = new Reader(bytes);
1309
+ const models = [];
1310
+ while (!reader.done) {
1311
+ const { field, wireType } = reader.tag();
1312
+ if (field === 1 && wireType === 2) {
1313
+ const model = decodeModelDetails(reader.bytes());
1314
+ if (model?.id) models.push(model);
1315
+ } else {
1316
+ reader.skip(wireType);
1317
+ }
1318
+ }
1319
+ return models;
1320
+ }
1321
+
1322
+ /** ModelDetails { model_id=1, display_model_id=3, display_name=4, display_name_short=5, aliases=6 } */
1323
+ function decodeModelDetails(bytes) {
1324
+ const reader = new Reader(bytes);
1325
+ const model = { id: "", name: "" };
1326
+ while (!reader.done) {
1327
+ const { field, wireType } = reader.tag();
1328
+ if (wireType === 2) {
1329
+ if (field === 1) model.id = reader.string();
1330
+ else if (field === 3) model.displayModelId = reader.string();
1331
+ else if (field === 4) model.name = reader.string();
1332
+ else if (field === 5) model.displayNameShort = reader.string();
1333
+ else if (field === 6) reader.bytes(); // aliases
1334
+ else reader.bytes();
1335
+ } else {
1336
+ reader.skip(wireType);
1337
+ }
1338
+ }
1339
+ return model;
1340
+ }
1341
+ //#endregion
1342
+
1343
+ //#region Connect framing
1344
+ /** Connect frame: [1-byte flags][4-byte big-endian length][payload]. */
1345
+ export function frameEncode(payload, flags = 0) {
1346
+ const out = new Uint8Array(5 + payload.length);
1347
+ const view = new DataView(out.buffer);
1348
+ view.setUint8(0, flags);
1349
+ view.setUint32(1, payload.length, false);
1350
+ out.set(payload, 5);
1351
+ return out;
1352
+ }
1353
+
1354
+ export const CONNECT_END_STREAM_FLAG = 0b00000010;
1355
+ export const CONNECT_COMPRESSED_FLAG = 0b00000001;
1356
+
1357
+ /** Upper bound for a decompressed Connect frame; guards against zip bombs. */
1358
+ export const MAX_CONNECT_FRAME_BYTES = 64 * 1024 * 1024;
1359
+
1360
+ /**
1361
+ * Incremental Connect frame parser fed by response chunks.
1362
+ * Yields { flags, payload } objects. Frames flagged compressed (bit 0) are
1363
+ * gzip-decompressed here so consumers only ever see plain payloads.
1364
+ */
1365
+ export class ConnectFrameReader {
1366
+ constructor() {
1367
+ this.buffer = new Uint8Array(0);
1368
+ this.frames = [];
1369
+ this.waiters = [];
1370
+ this.ended = false;
1371
+ this.error = undefined;
1372
+ }
1373
+
1374
+ push(chunk) {
1375
+ const combined = new Uint8Array(this.buffer.length + chunk.length);
1376
+ combined.set(this.buffer);
1377
+ combined.set(chunk, this.buffer.length);
1378
+ this.buffer = combined;
1379
+ while (this.buffer.length >= 5) {
1380
+ const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, 5);
1381
+ const flags = view.getUint8(0);
1382
+ const length = view.getUint32(1, false);
1383
+ if (this.buffer.length < 5 + length) break;
1384
+ const payload = this.buffer.slice(5, 5 + length);
1385
+ this.buffer = this.buffer.slice(5 + length);
1386
+ let data = payload;
1387
+ if ((flags & CONNECT_COMPRESSED_FLAG) !== 0) {
1388
+ try {
1389
+ data = gunzipSync(Buffer.from(payload), { maxOutputLength: MAX_CONNECT_FRAME_BYTES });
1390
+ } catch (error) {
1391
+ this.fail(new Error("Cursor sent an unreadable compressed frame", { cause: error }));
1392
+ return;
1393
+ }
1394
+ }
1395
+ this.#enqueue({ flags: flags & ~CONNECT_COMPRESSED_FLAG, payload: data });
1396
+ }
1397
+ }
1398
+
1399
+ #enqueue(frame) {
1400
+ if (this.waiters.length > 0) {
1401
+ const waiter = this.waiters.shift();
1402
+ waiter.resolve(frame);
1403
+ return;
1404
+ }
1405
+ this.frames.push(frame);
1406
+ }
1407
+
1408
+ finish() {
1409
+ this.ended = true;
1410
+ for (const waiter of this.waiters.splice(0)) waiter.resolve(undefined);
1411
+ }
1412
+
1413
+ fail(error) {
1414
+ this.error = error;
1415
+ this.ended = true;
1416
+ for (const waiter of this.waiters.splice(0)) waiter.reject(error);
1417
+ }
1418
+
1419
+ async next() {
1420
+ if (this.frames.length > 0) return this.frames.shift();
1421
+ if (this.ended) {
1422
+ if (this.error !== undefined) throw this.error;
1423
+ return undefined;
1424
+ }
1425
+ return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
1426
+ }
1427
+ }
1428
+ //#endregion
1429
+
1430
+ //#region agent transport
1431
+ const AGENT_HEADERS = {
1432
+ "content-type": "application/connect+proto",
1433
+ "connect-protocol-version": "1",
1434
+ "connect-accept-encoding": "gzip",
1435
+ te: "trailers",
1436
+ "x-ghost-mode": "true",
1437
+ "x-cursor-client-version": CURSOR_CLIENT_VERSION,
1438
+ "x-cursor-client-type": "cli",
1439
+ };
1440
+
1441
+ /**
1442
+ * One bidirectional agent run over HTTP/2. Writes Connect-framed
1443
+ * `AgentClientMessage`s and surfaces `AgentServerMessage`s through the frame
1444
+ * reader. Heartbeats keep the run alive.
1445
+ */
1446
+ export class AgentRun { constructor(accessToken, options = {}) {
1447
+ this.accessToken = accessToken;
1448
+ this.baseUrl = options.baseUrl ?? CURSOR_BASE_URL;
1449
+ this.session = undefined;
1450
+ this.frames = new ConnectFrameReader();
1451
+ this.responseStatus = undefined;
1452
+ this.responseContentType = undefined;
1453
+ this.trailers = {};
1454
+ this.finished = false;
1455
+ }
1456
+
1457
+ async start() {
1458
+ const url = new URL(CURSOR_RUN_PATH, this.baseUrl);
1459
+ const headers = {
1460
+ ":method": "POST",
1461
+ ":path": url.pathname,
1462
+ authorization: `Bearer ${this.accessToken}`,
1463
+ ...AGENT_HEADERS,
1464
+ };
1465
+ this.responsePromise = new Promise((resolve, reject) => {
1466
+ this.resolveResponse = resolve;
1467
+ this.rejectResponse = reject;
1468
+ });
1469
+ // The original promise remains rejectable for waitForResponse(); this
1470
+ // observer only prevents a close-before-wait race from becoming unhandled.
1471
+ void this.responsePromise.catch(() => {});
1472
+ this.session = http2.connect(this.baseUrl);
1473
+ this.stream = this.session.request(headers);
1474
+ // NOTE: never call stream.setEncoding() here — Node's setEncoding(null)
1475
+ // still decodes binary frames as UTF-8, corrupting any non-UTF-8 byte
1476
+ // (e.g. blob ids) into U+FFFD replacement characters.
1477
+ this.stream.on("response", (headers) => {
1478
+ this.responseStatus = Number(headers[":status"]);
1479
+ this.responseContentType = typeof headers["content-type"] === "string" ? headers["content-type"] : undefined;
1480
+ this.#resolveResponse(this.responseStatus);
1481
+ });
1482
+ this.stream.on("data", (chunk) => this.frames.push(Buffer.from(chunk)));
1483
+ this.stream.on("trailers", (trailers) => {
1484
+ this.trailers = trailers;
1485
+ });
1486
+ this.stream.on("end", () => this.frames.finish());
1487
+ this.stream.on("close", () => {
1488
+ if (this.responseStatus === undefined) this.#rejectResponse(new Error("Cursor HTTP stream closed before response"));
1489
+ });
1490
+ this.stream.on("error", (error) => {
1491
+ this.#rejectResponse(error);
1492
+ this.frames.fail(error);
1493
+ });
1494
+ this.session.on("error", (error) => {
1495
+ this.#rejectResponse(error);
1496
+ this.frames.fail(error);
1497
+ });
1498
+ }
1499
+
1500
+ #resolveResponse(status) {
1501
+ const resolve = this.resolveResponse;
1502
+ this.resolveResponse = undefined;
1503
+ this.rejectResponse = undefined;
1504
+ resolve?.(status);
1505
+ }
1506
+
1507
+ #rejectResponse(error) {
1508
+ const reject = this.rejectResponse;
1509
+ this.resolveResponse = undefined;
1510
+ this.rejectResponse = undefined;
1511
+ reject?.(error);
1512
+ }
1513
+
1514
+ waitForResponse(timeoutMs) {
1515
+ if (this.responsePromise === undefined) throw new Error("Cursor AgentRun has not started");
1516
+ if (timeoutMs === undefined) return this.responsePromise;
1517
+ return new Promise((resolve, reject) => {
1518
+ const timer = setTimeout(() => reject(new Error("Cursor HTTP response timeout")), timeoutMs);
1519
+ timer.unref?.();
1520
+ this.responsePromise.then(resolve, reject).finally(() => clearTimeout(timer));
1521
+ });
1522
+ }
1523
+
1524
+ write(payload) {
1525
+ if (this.finished || this.stream === undefined || this.stream.destroyed) return false;
1526
+ try {
1527
+ this.stream.write(Buffer.from(payload));
1528
+ return true;
1529
+ } catch {
1530
+ return false;
1531
+ }
1532
+ }
1533
+
1534
+ writeMessage(bytes) {
1535
+ return this.write(frameEncode(bytes));
1536
+ }
1537
+
1538
+ startHeartbeat() {
1539
+ this.heartbeat = setInterval(() => {
1540
+ this.writeMessage(encodeHeartbeat());
1541
+ }, HEARTBEAT_INTERVAL_MS);
1542
+ this.heartbeat.unref?.();
1543
+ }
1544
+
1545
+ /** Fail the run with a terminal error and tear down the connection. */
1546
+ abort(error) {
1547
+ if (this.finished) return;
1548
+ this.#rejectResponse(error);
1549
+ this.frames.fail(error);
1550
+ this.close();
1551
+ }
1552
+
1553
+ close() {
1554
+ if (this.finished) return;
1555
+ this.#rejectResponse(new Error("Cursor AgentRun closed before response"));
1556
+ this.finished = true;
1557
+ if (this.heartbeat !== undefined) clearInterval(this.heartbeat);
1558
+ if (!this.frames.ended) this.frames.finish();
1559
+ try {
1560
+ this.stream?.close();
1561
+ } catch {}
1562
+ // Force-destroy the session so no socket keeps the process alive after a
1563
+ // completed run; the agent response is fully consumed by then.
1564
+ try {
1565
+ this.session.destroy();
1566
+ } catch {}
1567
+ }
1568
+ }
1569
+ //#endregion
1570
+
1571
+ //#region conversation building
1572
+ function sha256(bytes) {
1573
+ return createHash("sha256").update(bytes).digest();
1574
+ }
1575
+
1576
+ function flattenBlocks(content) {
1577
+ let text = "";
1578
+ for (const block of content ?? []) {
1579
+ if (block.type === "text") text += block.text;
1580
+ else if (block.type === "tool-result") {
1581
+ const callId = block.toolCallId ? ` for ${block.toolCallId}` : "";
1582
+ text += `\n[TOOL RESULT${callId}]\n${flattenBlocks(block.content)}`;
1583
+ }
1584
+ }
1585
+ return text;
1586
+ }
1587
+
1588
+ function renderAssistant(content) {
1589
+ let text = "";
1590
+ for (const block of content ?? []) {
1591
+ if (block.type === "text") text += block.text;
1592
+ else if (block.type === "tool-call") {
1593
+ text += `\n<tool_call id="${block.id}" name="${block.name}">${block.arguments}</tool_call>`;
1594
+ } else if (block.type === "reasoning") {
1595
+ text += `\n<thinking>${block.text}</thinking>`;
1596
+ }
1597
+ }
1598
+ return text.trim();
1599
+ }
1600
+
1601
+ function sanitizeReplayedAssistantText(text) {
1602
+ let omitted = false;
1603
+ const sanitized = String(text ?? "").replace(/<tool_call\b[\s\S]*?<\/tool_call>/gi, () => {
1604
+ omitted = true;
1605
+ return "";
1606
+ }).replace(/<\/?invoke>|<\|eos\|>/gi, "").trim();
1607
+ if (!omitted) return sanitized;
1608
+ return `${sanitized}${sanitized ? "\n" : ""}[Previous textual tool-call markup omitted from replay.]`;
1609
+ }
1610
+
1611
+ /** Replay assistant history without teaching models to imitate XML tool tags. */
1612
+ function renderColdStartAssistant(content) {
1613
+ const parts = [];
1614
+ for (const block of content ?? []) {
1615
+ if (block.type === "text") {
1616
+ const text = sanitizeReplayedAssistantText(block.text);
1617
+ if (text) parts.push(text);
1618
+ } else if (block.type === "tool-call") {
1619
+ parts.push(`[Previous tool request: ${block.name}]`);
1620
+ }
1621
+ }
1622
+ return parts.join("\n").trim();
1623
+ }
1624
+
1625
+ function actionBoundary(messages) {
1626
+ for (let index = messages.length - 1; index >= 0; index--) {
1627
+ if (messages[index].role === "assistant") return index + 1;
1628
+ }
1629
+ return 0;
1630
+ }
1631
+
1632
+ /** Combine the current DSH turn's adjacent user/context messages into one action. */
1633
+ function currentActionText(options) {
1634
+ const messages = options.messages ?? [];
1635
+ const boundary = actionBoundary(messages);
1636
+ const parts = [];
1637
+ for (let index = boundary; index < messages.length; index++) {
1638
+ const message = messages[index];
1639
+ if (message.role !== "user") continue;
1640
+ const text = flattenBlocks(message.content).trim();
1641
+ if (text.length > 0) parts.push(text);
1642
+ }
1643
+ if (parts.length > 0) return parts.join("\n\n");
1644
+ for (let index = messages.length - 1; index >= 0; index--) {
1645
+ if (messages[index].role !== "user") continue;
1646
+ const text = flattenBlocks(messages[index].content).trim();
1647
+ if (text.length > 0) return text;
1648
+ }
1649
+ return "";
1650
+ }
1651
+
1652
+ /** Extract DSH tool-result blocks for resuming a live Cursor exec bridge. */
1653
+ function collectToolResults(options) {
1654
+ const results = [];
1655
+ for (const message of options.messages ?? []) {
1656
+ for (const block of message.content ?? []) {
1657
+ if (block.type !== "tool-result") continue;
1658
+ results.push({
1659
+ toolCallId: block.toolCallId ?? message.source?.callId,
1660
+ content: flattenBlocks(block.content).trim(),
1661
+ isError: block.isError === true,
1662
+ });
1663
+ }
1664
+ }
1665
+ return results;
1666
+ }
1667
+
1668
+ function decodeXmlText(value) {
1669
+ return String(value ?? "")
1670
+ .replace(/&quot;/gi, '"')
1671
+ .replace(/&apos;/gi, "'")
1672
+ .replace(/&lt;/gi, "<")
1673
+ .replace(/&gt;/gi, ">")
1674
+ .replace(/&amp;/gi, "&");
1675
+ }
1676
+
1677
+ function parseTagAttributes(source) {
1678
+ const attributes = {};
1679
+ const pattern = /([A-Za-z_][\w-]*)\s*=\s*"([\s\S]*?)"/g;
1680
+ for (let match; (match = pattern.exec(source)) !== null;) attributes[match[1]] = decodeXmlText(match[2]);
1681
+ return attributes;
1682
+ }
1683
+
1684
+ function resolveTextTool(candidate, tools) {
1685
+ const available = tools ?? [];
1686
+ const normalized = String(candidate ?? "").toLowerCase();
1687
+ let tool = available.find((entry) => {
1688
+ const name = String(entry.name).toLowerCase();
1689
+ return normalized === name || normalized.endsWith(`_${name}`);
1690
+ });
1691
+ if (tool !== undefined) return tool;
1692
+ const aliases = {
1693
+ shell: ["pwsh", "bash"],
1694
+ read: ["read"],
1695
+ glob: ["glob"],
1696
+ grep: ["grep"],
1697
+ write: ["write"],
1698
+ edit: ["edit"],
1699
+ };
1700
+ for (const name of aliases[normalized] ?? []) {
1701
+ tool = available.find((entry) => String(entry.name).toLowerCase() === name);
1702
+ if (tool !== undefined) return tool;
1703
+ }
1704
+ return undefined;
1705
+ }
1706
+
1707
+ function normalizeTextToolArguments(tool, raw) {
1708
+ const args = { ...raw };
1709
+ if (tool.name === "read" && args.file_path === undefined && args.path !== undefined) {
1710
+ args.file_path = args.path;
1711
+ delete args.path;
1712
+ }
1713
+ if (tool.name === "glob") {
1714
+ if (args.pattern === undefined && args.glob_pattern !== undefined) args.pattern = args.glob_pattern;
1715
+ if (args.path === undefined && args.target_directory !== undefined) args.path = args.target_directory;
1716
+ delete args.glob_pattern;
1717
+ delete args.target_directory;
1718
+ }
1719
+ for (const [key, value] of Object.entries(args)) {
1720
+ const type = tool.parameters?.properties?.[key]?.type;
1721
+ if ((type === "number" || type === "integer") && typeof value === "string" && value.trim() !== "") {
1722
+ const number = Number(value);
1723
+ if (Number.isFinite(number)) args[key] = number;
1724
+ } else if (type === "boolean" && typeof value === "string") {
1725
+ if (value.toLowerCase() === "true") args[key] = true;
1726
+ else if (value.toLowerCase() === "false") args[key] = false;
1727
+ } else if ((type === "object" || type === "array") && typeof value === "string") {
1728
+ try { args[key] = JSON.parse(value); } catch {}
1729
+ }
1730
+ }
1731
+ return args;
1732
+ }
1733
+
1734
+ /**
1735
+ * Recover Cursor models that print tool-call XML as text instead of emitting
1736
+ * an execServerMessage.mcpArgs frame. This is a fallback for contaminated or
1737
+ * compacted conversations; protocol-native MCP calls remain the primary path.
1738
+ */
1739
+ export function parseTextToolCalls(text, tools) {
1740
+ const calls = [];
1741
+ const pattern = /<tool_call\b([^>]*)>([\s\S]*?)<\/tool_call>/gi;
1742
+ for (let match; (match = pattern.exec(String(text ?? ""))) !== null;) {
1743
+ const attributes = parseTagAttributes(match[1]);
1744
+ const tool = resolveTextTool(attributes.name ?? attributes.id, tools);
1745
+ if (tool === undefined) continue;
1746
+ const rawArgs = {};
1747
+ const parameterPattern = /<parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/parameter>/gi;
1748
+ for (let parameter; (parameter = parameterPattern.exec(match[2])) !== null;) {
1749
+ rawArgs[parameter[1]] = decodeXmlText(parameter[2].trim());
1750
+ }
1751
+ for (const [key, value] of Object.entries(attributes)) {
1752
+ if (key !== "id" && key !== "name") rawArgs[key] = value;
1753
+ }
1754
+ if (Object.keys(rawArgs).length === 0) {
1755
+ const body = decodeXmlText(match[2]).trim();
1756
+ if (body.startsWith("{")) {
1757
+ try { Object.assign(rawArgs, JSON.parse(body)); } catch {}
1758
+ }
1759
+ }
1760
+ calls.push({
1761
+ id: `text-tool-${crypto.randomUUID()}`,
1762
+ name: tool.name,
1763
+ arguments: JSON.stringify(normalizeTextToolArguments(tool, rawArgs)),
1764
+ });
1765
+ }
1766
+ return calls;
1767
+ }
1768
+
1769
+ function coldStartLabel(message) {
1770
+ if (message.role === "assistant") return "ASSISTANT";
1771
+ if ((message.content ?? []).some((block) => block.type === "tool-result")) return "TOOL RESULT";
1772
+ if (message.source?.kind === "plugin") return "RUNTIME CONTEXT";
1773
+ return "USER";
1774
+ }
1775
+
1776
+ /**
1777
+ * Rehydrate a DSH conversation after process restart without using Cursor's
1778
+ * field-8 turns (the current server interprets those entries as blob ids).
1779
+ */
1780
+ function coldStartActionText(options) {
1781
+ const entries = [];
1782
+ for (const message of options.messages ?? []) {
1783
+ if (message.role === "system") continue;
1784
+ const text = message.role === "assistant" ? renderColdStartAssistant(message.content) : flattenBlocks(message.content).trim();
1785
+ if (text.length === 0) continue;
1786
+ entries.push({ label: coldStartLabel(message), text });
1787
+ }
1788
+ if (entries.length === 0) return "";
1789
+ if (entries.length === 1 && entries[0].label === "USER") return entries[0].text;
1790
+ return [
1791
+ "Continue the DSH conversation below. Treat entries according to their labels. Respond to the final USER request; RUNTIME CONTEXT and TOOL RESULT entries provide context only.",
1792
+ ...entries.map((entry) => `[${entry.label}]\n${entry.text}`),
1793
+ ].join("\n\n");
1794
+ }
1795
+
1796
+ /**
1797
+ * Build a fresh state with root prompt blobs only. Current Cursor servers reject
1798
+ * hand-encoded field-8 turns, so cold starts carry textual history in the action.
1799
+ */
1800
+ export function buildInitialConversationState(options) {
1801
+ const systemParts = [options.system ?? ""];
1802
+ for (const message of options.messages ?? []) {
1803
+ if (message.role === "system") systemParts.push(flattenBlocks(message.content));
1804
+ }
1805
+ const systemText = systemParts.filter((part) => part.length > 0).join("\n").trim();
1806
+ const blobStore = new Map();
1807
+ const rootPromptBlobIds = [];
1808
+ if (systemText.length > 0) {
1809
+ const payload = textEncoder.encode(JSON.stringify({ role: "system", content: systemText }));
1810
+ const id = sha256(payload);
1811
+ blobStore.set(Buffer.from(id).toString("hex"), payload);
1812
+ rootPromptBlobIds.push(id);
1813
+ }
1814
+ return {
1815
+ conversationState: encodeConversationState({ rootPromptBlobIds, turns: [], systemText }),
1816
+ blobStore,
1817
+ systemText,
1818
+ };
1819
+ }
1820
+
1821
+ /**
1822
+ * Fold DSH history into Cursor's turn-based conversation state.
1823
+ *
1824
+ * The last user message becomes the new `ConversationAction`; everything
1825
+ * before it is the durable turn history. Tool results are rendered as user
1826
+ * messages so a follow-up run after DSH executes a tool carries the result
1827
+ * back to the model. The system prompt travels as a blob referenced by
1828
+ * `rootPromptMessagesJson`; the server fetches it through the KV handshake.
1829
+ */
1830
+ export function buildConversationState(options) {
1831
+ const systemParts = [options.system ?? ""];
1832
+ const turns = [];
1833
+ let currentUser = null;
1834
+ let currentSteps = [];
1835
+
1836
+ const flushTurn = () => {
1837
+ if (currentUser !== null) {
1838
+ const userBytes = encodeUserMessage({ text: currentUser, messageId: randomUUID() });
1839
+ const turnBytes = encodeAgentTurn(userBytes, currentSteps);
1840
+ turns.push(encodeTurnStructure(turnBytes));
1841
+ }
1842
+ currentUser = null;
1843
+ currentSteps = [];
1844
+ };
1845
+
1846
+ for (const message of options.messages ?? []) {
1847
+ if (message.role === "system") {
1848
+ systemParts.push(flattenBlocks(message.content));
1849
+ continue;
1850
+ }
1851
+ if (message.role === "user") {
1852
+ flushTurn();
1853
+ currentUser = flattenBlocks(message.content);
1854
+ continue;
1855
+ }
1856
+ if (message.role === "assistant") {
1857
+ const text = renderAssistant(message.content);
1858
+ if (currentUser === null) currentUser = ""; // assistant-first history: implicit turn
1859
+ if (text.length > 0) currentSteps.push(encodeAssistantStep(text));
1860
+ }
1861
+ }
1862
+
1863
+ // The final pending user message is the action, not part of history.
1864
+ let actionText = "";
1865
+ if (currentUser !== null) {
1866
+ actionText = currentUser;
1867
+ }
1868
+ currentUser = null;
1869
+ currentSteps = [];
1870
+
1871
+ const systemText = systemParts.filter((part) => part.length > 0).join("\n").trim();
1872
+ const blobStore = new Map();
1873
+ const rootPromptBlobIds = [];
1874
+ if (systemText.length > 0) {
1875
+ const payload = textEncoder.encode(JSON.stringify({ role: "system", content: systemText }));
1876
+ const id = sha256(payload);
1877
+ blobStore.set(Buffer.from(id).toString("hex"), payload);
1878
+ rootPromptBlobIds.push(id);
1879
+ }
1880
+
1881
+ const conversationState = encodeConversationState({ rootPromptBlobIds, turns, systemText });
1882
+ const userBytes = encodeUserMessage({ text: actionText, messageId: randomUUID() });
1883
+ const action = encodeUserMessageAction(userBytes);
1884
+ return { conversationState, action, blobStore, systemText };
1885
+ }
1886
+
1887
+ /**
1888
+ * Build the full `AgentClientMessage` payload for one run.
1889
+ *
1890
+ * When a persisted checkpoint for this conversation exists (captured from a
1891
+ * previous run's `conversation_checkpoint_update`), it becomes the
1892
+ * conversation state and its referenced blobs are served from the persisted
1893
+ * blob store. Otherwise the state is built from the DSH message history.
1894
+ */
1895
+ export function buildRunPayload(options, modelId, persisted) {
1896
+ let conversationState;
1897
+ let blobStore;
1898
+ let actionText;
1899
+ if (persisted?.checkpoint !== undefined) {
1900
+ // The checkpoint already encodes prior turns. DSH may append both the
1901
+ // human request and a runtime-context user message, so combine the whole
1902
+ // trailing user group rather than selecting only the final message.
1903
+ conversationState = persisted.checkpoint;
1904
+ blobStore = persisted.blobs ?? new Map();
1905
+ actionText = currentActionText(options);
1906
+ } else {
1907
+ // On first use or after a DSH restart there is no server checkpoint. Never
1908
+ // hand-encode field-8 turns: current Cursor servers treat them as blob ids.
1909
+ const built = buildInitialConversationState(options);
1910
+ conversationState = built.conversationState;
1911
+ blobStore = built.blobStore;
1912
+ actionText = coldStartActionText(options);
1913
+ }
1914
+ const userBytes = encodeUserMessage({ text: actionText, messageId: randomUUID() });
1915
+ const action = encodeUserMessageAction(userBytes);
1916
+ const modelDetails = encodeModelDetails(modelId);
1917
+ const runRequest = encodeRunRequest({
1918
+ conversationState,
1919
+ action,
1920
+ modelDetails,
1921
+ conversationId: randomUUID(),
1922
+ });
1923
+ return { payload: encodeRunMessage(runRequest), blobStore };
1924
+ }
1925
+
1926
+ //#endregion
1927
+
1928
+ //#region models discovery
1929
+ /**
1930
+ * Fetch usable models from Cursor's unary `GetUsableModels` endpoint.
1931
+ * The request body is the raw (unframed) empty protobuf message.
1932
+ */
1933
+ export async function fetchUsableModels(accessToken, options = {}) {
1934
+ const fetchImpl = options.fetch ?? nativeFetch;
1935
+ const response = await fetchImpl(`${CURSOR_BASE_URL}${CURSOR_MODELS_PATH}`, {
1936
+ method: "POST",
1937
+ redirect: "error",
1938
+ headers: {
1939
+ "content-type": "application/proto",
1940
+ authorization: `Bearer ${accessToken}`,
1941
+ "x-ghost-mode": "true",
1942
+ "x-cursor-client-version": CURSOR_CLIENT_VERSION,
1943
+ "x-cursor-client-type": "cli",
1944
+ "user-agent": "dsh-cursor-subscription/0.1.0",
1945
+ },
1946
+ body: new Uint8Array(0),
1947
+ signal: options.signal,
1948
+ });
1949
+ if (!response.ok) throw new Error(`Cursor model discovery failed (HTTP ${response.status})`);
1950
+ const bytes = new Uint8Array(await response.arrayBuffer());
1951
+ if (bytes.length === 0) throw new Error("Cursor model discovery returned an empty response");
1952
+ // The body may be raw protobuf or Connect-framed; try both.
1953
+ const models = decodeUsableModels(bytes);
1954
+ if (models.length > 0) return models;
1955
+ const frameReader = new ConnectFrameReader();
1956
+ frameReader.push(bytes);
1957
+ frameReader.finish();
1958
+ const framed = [];
1959
+ for (;;) {
1960
+ const frame = await frameReader.next();
1961
+ if (frame === undefined) break;
1962
+ if ((frame.flags & CONNECT_END_STREAM_FLAG) !== 0) break;
1963
+ framed.push(...decodeUsableModels(frame.payload));
1964
+ }
1965
+ return framed;
1966
+ }
1967
+ //#endregion
1968
+
1969
+ //#region usage
1970
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1971
+
1972
+ /**
1973
+ * Extract the legacy request bucket from the `/api/usage` model map. Cursor's
1974
+ * dashboard hardcodes the `"gpt-4"` entry for the legacy quota; when absent we
1975
+ * fall back to the quota-bearing bucket with the largest limit.
1976
+ */
1977
+ export function parseLegacyBucket(json) {
1978
+ if (!record(json)) return undefined;
1979
+ const gpt4 = json["gpt-4"];
1980
+ if (record(gpt4) && typeof gpt4.numRequests === "number") {
1981
+ return { numRequests: gpt4.numRequests, maxRequestUsage: gpt4.maxRequestUsage };
1982
+ }
1983
+ let best;
1984
+ let bestLimit = 0;
1985
+ for (const value of Object.values(json)) {
1986
+ if (record(value) && typeof value.numRequests === "number") {
1987
+ const limit = typeof value.maxRequestUsage === "number" ? value.maxRequestUsage : 0;
1988
+ if (limit > bestLimit) {
1989
+ bestLimit = limit;
1990
+ best = { numRequests: value.numRequests, maxRequestUsage: value.maxRequestUsage };
1991
+ }
1992
+ }
1993
+ }
1994
+ return best;
1995
+ }
1996
+
1997
+ /** Cursor converts included on-plan spend (cents) to requests at ~4 cents each. */
1998
+ export function getRequestCountFromSpendCents(cents) {
1999
+ return typeof cents === "number" && cents > 0 ? Math.ceil(cents / 4) : 0;
2000
+ }
2001
+
2002
+ /**
2003
+ * Compute included-request usage the way Cursor's dashboard does:
2004
+ * `used = team ? ceil(spendCents/4) : legacy.numRequests`,
2005
+ * `limit = team ? 500 * requestQuotaPerSeat : legacy.maxRequestUsage`.
2006
+ */
2007
+ export function computeIncludedRequests({ legacy, isTeam, planUsedCents, requestQuotaPerSeat }) {
2008
+ const usedFromSpend =
2009
+ typeof planUsedCents === "number" && planUsedCents > 0 ? getRequestCountFromSpendCents(planUsedCents) : undefined;
2010
+ const used = isTeam ? usedFromSpend ?? legacy?.numRequests : legacy?.numRequests;
2011
+ const limit = isTeam && typeof requestQuotaPerSeat === "number" ? 500 * requestQuotaPerSeat : legacy?.maxRequestUsage;
2012
+ if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return undefined;
2013
+ return {
2014
+ used,
2015
+ limit,
2016
+ remaining: Math.max(0, limit - used),
2017
+ pct: Math.round((used / limit) * 1000) / 10,
2018
+ };
2019
+ }
2020
+
2021
+ function centsToDollars(cents) {
2022
+ return typeof cents === "number" && Number.isFinite(cents) ? Math.round((cents / 100) * 100) / 100 : undefined;
2023
+ }
2024
+
2025
+ function asFiniteNumber(value) {
2026
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2027
+ if (typeof value === "string" && value.trim() !== "") {
2028
+ const n = Number(value);
2029
+ if (Number.isFinite(n)) return n;
2030
+ }
2031
+ return undefined;
2032
+ }
2033
+
2034
+ function firstFinite(...values) {
2035
+ for (const value of values) {
2036
+ const n = asFiniteNumber(value);
2037
+ if (n !== undefined) return n;
2038
+ }
2039
+ return undefined;
2040
+ }
2041
+
2042
+ function percentFromUsageMessage(message) {
2043
+ if (typeof message !== "string") return undefined;
2044
+ const match = message.match(/(\d+(?:\.\d+)?)\s*%/);
2045
+ return match ? asFiniteNumber(match[1]) : undefined;
2046
+ }
2047
+
2048
+ /** Project `/api/usage-summary` into browser-safe usage facts. */
2049
+ export function parseUsageSummary(json) {
2050
+ if (!record(json)) return {};
2051
+ const out = {};
2052
+ if (typeof json.membershipType === "string") out.membershipType = json.membershipType;
2053
+ if (typeof json.isUnlimited === "boolean") out.isUnlimited = json.isUnlimited;
2054
+ if (typeof json.billingCycleStart === "string") out.billingCycleStart = json.billingCycleStart;
2055
+ if (typeof json.billingCycleEnd === "string") out.billingCycleEnd = json.billingCycleEnd;
2056
+ if (json.limitType === "team") out.isTeam = true;
2057
+ const individualPlan = record(json.individualUsage?.plan) ? json.individualUsage.plan : undefined;
2058
+ const teamPlan = record(json.teamUsage?.plan) ? json.teamUsage.plan : undefined;
2059
+ if (individualPlan !== undefined || teamPlan !== undefined) {
2060
+ const used = firstFinite(individualPlan?.used, teamPlan?.used);
2061
+ const limit = firstFinite(individualPlan?.limit, teamPlan?.limit);
2062
+ const totalPercentUsed = firstFinite(individualPlan?.totalPercentUsed, teamPlan?.totalPercentUsed);
2063
+ const autoPercentUsed = firstFinite(individualPlan?.autoPercentUsed, teamPlan?.autoPercentUsed);
2064
+ let apiPercentUsed = firstFinite(individualPlan?.apiPercentUsed, teamPlan?.apiPercentUsed);
2065
+ if (apiPercentUsed === undefined) {
2066
+ apiPercentUsed = percentFromUsageMessage(json.namedModelSelectedDisplayMessage);
2067
+ }
2068
+ if (used !== undefined) out.planUsedCents = used;
2069
+ if (limit !== undefined) out.planLimitCents = limit;
2070
+ if (totalPercentUsed !== undefined) out.totalPercentUsed = totalPercentUsed;
2071
+ if (autoPercentUsed !== undefined) out.autoPercentUsed = autoPercentUsed;
2072
+ if (apiPercentUsed !== undefined) out.apiPercentUsed = apiPercentUsed;
2073
+ }
2074
+ const onDemand = json.individualUsage?.onDemand;
2075
+ if (record(onDemand)) {
2076
+ const usedDollars = centsToDollars(onDemand.used);
2077
+ const limitDollars = centsToDollars(onDemand.limit);
2078
+ const remainingDollars = centsToDollars(onDemand.remaining);
2079
+ if (usedDollars !== undefined || limitDollars !== undefined) {
2080
+ out.individualOnDemand = {
2081
+ ...usedDollars === undefined ? {} : { usedDollars },
2082
+ ...limitDollars === undefined ? {} : { limitDollars },
2083
+ ...remainingDollars === undefined ? {} : { remainingDollars },
2084
+ };
2085
+ }
2086
+ }
2087
+ const teamOnDemand = json.teamUsage?.onDemand;
2088
+ if (record(teamOnDemand)) {
2089
+ const usedDollars = centsToDollars(teamOnDemand.used);
2090
+ const limitDollars = centsToDollars(teamOnDemand.limit);
2091
+ const remainingDollars = centsToDollars(teamOnDemand.remaining);
2092
+ if (usedDollars !== undefined || limitDollars !== undefined) {
2093
+ out.teamOnDemand = {
2094
+ ...usedDollars === undefined ? {} : { usedDollars },
2095
+ ...limitDollars === undefined ? {} : { limitDollars },
2096
+ ...remainingDollars === undefined ? {} : { remainingDollars },
2097
+ };
2098
+ }
2099
+ }
2100
+ return out;
2101
+ }
2102
+
2103
+ /**
2104
+ * Project `/api/dashboard/get-aggregated-usage-events` into a browser-safe
2105
+ * per-model spend list. Cursor's named/API models (Claude, Grok, GPT, …) live
2106
+ * here; `/api/usage` only exposes the legacy `gpt-4` quota bucket.
2107
+ */
2108
+ export function parseModelAggregations(json, limit = MAX_USAGE_MODELS) {
2109
+ if (!record(json) || !Array.isArray(json.aggregations)) return [];
2110
+ const rows = [];
2111
+ for (const entry of json.aggregations) {
2112
+ if (!record(entry) || typeof entry.modelIntent !== "string") continue;
2113
+ const id = entry.modelIntent.trim();
2114
+ if (!id) continue;
2115
+ const cents = typeof entry.totalCents === "number" ? entry.totalCents : Number(entry.totalCents);
2116
+ if (!Number.isFinite(cents) || cents <= 0) continue;
2117
+ rows.push({ id, cents });
2118
+ }
2119
+ rows.sort((a, b) => b.cents - a.cents);
2120
+ const totalCents =
2121
+ typeof json.totalCostCents === "number" && Number.isFinite(json.totalCostCents) && json.totalCostCents > 0
2122
+ ? json.totalCostCents
2123
+ : rows.reduce((sum, row) => sum + row.cents, 0);
2124
+ const cap = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), rows.length) : rows.length;
2125
+ const models = [];
2126
+ for (let index = 0; index < cap; index++) {
2127
+ const row = rows[index];
2128
+ models.push({
2129
+ id: row.id,
2130
+ spentDollars: Math.round((row.cents / 100) * 100) / 100,
2131
+ pct: totalCents > 0 ? Math.round((row.cents / totalCents) * 1000) / 10 : 0,
2132
+ });
2133
+ }
2134
+ return models;
2135
+ }
2136
+
2137
+ /** Pull `requestQuotaPerSeat` for the active team from `/api/dashboard/teams`. */
2138
+ export function parseRequestQuotaPerSeat(json, teamId) {
2139
+ if (!record(json) || !Array.isArray(json.teams)) return undefined;
2140
+ const match = (teamId ? json.teams.find((t) => String(t?.id) === String(teamId)) : undefined) ?? json.teams[0];
2141
+ const quota = match?.requestQuotaPerSeat ?? match?.request_quota_per_seat;
2142
+ return typeof quota === "number" && Number.isFinite(quota) ? quota : undefined;
2143
+ }
2144
+
2145
+ function usageDaysLeft(billingCycleEnd, now) {
2146
+ const end = new Date(billingCycleEnd).getTime();
2147
+ if (!Number.isFinite(end)) return undefined;
2148
+ return Math.max(0, Math.ceil((end - now) / (24 * 60 * 60 * 1000)));
2149
+ }
2150
+
2151
+ /**
2152
+ * Read usage from Cursor's dashboard endpoints using the same session cookie
2153
+ * the web dashboard sends. The browser receives only the parsed projection;
2154
+ * the access token and cookie are request-local host values. Concurrent polls
2155
+ * share one in-flight request; successful reads are cached for a short TTL.
2156
+ */
2157
+ export class CursorUsageReader {
2158
+ constructor(auth, options = {}) {
2159
+ this.auth = auth;
2160
+ this.fetch = options.fetch ?? nativeFetch;
2161
+ this.now = options.now ?? Date.now;
2162
+ this.ttlMs = options.ttlMs ?? USAGE_TTL_MS;
2163
+ this.logger = options.logger;
2164
+ this.#cache = { at: 0, value: undefined };
2165
+ }
2166
+
2167
+ #cache;
2168
+
2169
+ #log(level, message, ...args) {
2170
+ try {
2171
+ this.logger?.[level]?.(`cursor-subscription: ${message}`, ...args);
2172
+ } catch {}
2173
+ }
2174
+
2175
+ async #request(url, cookie, options = {}) {
2176
+ const response = await this.fetch(url, {
2177
+ method: options.method ?? "GET",
2178
+ redirect: "follow",
2179
+ headers: {
2180
+ accept: "application/json",
2181
+ origin: USAGE_API_ORIGIN,
2182
+ referer: "https://cursor.com/dashboard",
2183
+ cookie,
2184
+ "user-agent": "dsh-cursor-subscription/0.2.0",
2185
+ ...(options.body === undefined ? {} : { "content-type": "application/json" }),
2186
+ },
2187
+ ...options.body === undefined ? {} : { body: JSON.stringify(options.body) },
2188
+ signal: options.signal,
2189
+ });
2190
+ if (!response.ok) throw new Error(`HTTP ${response.status} from ${url}`);
2191
+ const text = await response.text();
2192
+ try {
2193
+ return JSON.parse(text);
2194
+ } catch (error) {
2195
+ throw new Error(`unreadable JSON from ${url}`, { cause: error });
2196
+ }
2197
+ }
2198
+
2199
+ async #tryRequest(url, cookie, options = {}) {
2200
+ try {
2201
+ return { ok: true, value: await this.#request(url, cookie, options) };
2202
+ } catch (error) {
2203
+ this.#log("warn", "usage request failed: %s", error instanceof Error ? error.message : error);
2204
+ return { ok: false, error };
2205
+ }
2206
+ }
2207
+
2208
+ #mergeSignals(signal, timeoutMs) {
2209
+ const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 && typeof AbortSignal.timeout === "function"
2210
+ ? AbortSignal.timeout(timeoutMs)
2211
+ : undefined;
2212
+ if (signal === undefined) return timeout;
2213
+ if (timeout === undefined) return signal;
2214
+ return typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal;
2215
+ }
2216
+
2217
+ async read({ force = false, signal } = {}) {
2218
+ const now = this.now();
2219
+ if (!force && this.#cache.value !== undefined && now - this.#cache.at < this.ttlMs) {
2220
+ return structuredClone(this.#cache.value);
2221
+ }
2222
+ const credential = await this.auth.credential({ signal });
2223
+ const userSub = getTokenSub(credential.access);
2224
+ if (userSub === undefined) throw new Error("Cursor session token has no usable user identity");
2225
+ const cookie = `WorkosCursorSessionToken=${userSub}::${credential.access}`;
2226
+
2227
+ const [usageResult, summaryResult, teamsResult] = await Promise.all([
2228
+ this.#tryRequest(`${USAGE_URL}?user=${encodeURIComponent(userSub)}`, cookie, { signal }),
2229
+ this.#tryRequest(USAGE_SUMMARY_URL, cookie, { signal }),
2230
+ this.#tryRequest(USAGE_TEAMS_URL, cookie, { method: "POST", body: { activeOnly: false }, signal }),
2231
+ ]);
2232
+ if (!usageResult.ok && !summaryResult.ok) {
2233
+ const failed = summaryResult.error ?? usageResult.error;
2234
+ throw failed instanceof Error ? failed : new Error("Could not read Cursor usage");
2235
+ }
2236
+ const usage = usageResult.ok ? usageResult.value : undefined;
2237
+ const summary = summaryResult.ok ? summaryResult.value : undefined;
2238
+ const teams = teamsResult.ok ? teamsResult.value : undefined;
2239
+
2240
+ const legacy = parseLegacyBucket(usage);
2241
+ const parsed = parseUsageSummary(summary);
2242
+ const requestQuotaPerSeat = parseRequestQuotaPerSeat(teams);
2243
+ const includedRequests =
2244
+ computeIncludedRequests({
2245
+ legacy,
2246
+ isTeam: parsed.isTeam === true,
2247
+ planUsedCents: parsed.planUsedCents,
2248
+ requestQuotaPerSeat,
2249
+ }) ?? (legacy !== undefined ? computeIncludedRequests({ legacy, isTeam: false }) : undefined);
2250
+
2251
+ const models = await this.#readModelUsage(cookie, parsed, now, signal);
2252
+ const hasPlan =
2253
+ parsed.totalPercentUsed !== undefined || parsed.autoPercentUsed !== undefined || parsed.apiPercentUsed !== undefined;
2254
+
2255
+ const value = {
2256
+ fetchedAt: now,
2257
+ membershipType: parsed.membershipType,
2258
+ isUnlimited: parsed.isUnlimited,
2259
+ isTeam: parsed.isTeam,
2260
+ billingCycle: parsed.billingCycleEnd === undefined ? undefined : {
2261
+ start: parsed.billingCycleStart,
2262
+ end: parsed.billingCycleEnd,
2263
+ daysLeft: usageDaysLeft(parsed.billingCycleEnd, now),
2264
+ },
2265
+ plan: hasPlan ? {
2266
+ ...parsed.totalPercentUsed === undefined ? {} : { totalPercentUsed: parsed.totalPercentUsed },
2267
+ ...parsed.autoPercentUsed === undefined ? {} : { autoPercentUsed: parsed.autoPercentUsed },
2268
+ ...parsed.apiPercentUsed === undefined ? {} : { apiPercentUsed: parsed.apiPercentUsed },
2269
+ } : undefined,
2270
+ includedRequests,
2271
+ individualOnDemand: parsed.individualOnDemand,
2272
+ teamOnDemand: parsed.teamOnDemand,
2273
+ models,
2274
+ };
2275
+ this.#cache = { at: now, value };
2276
+ this.#log("info", "usage read: %s", JSON.stringify({ membershipType: value.membershipType, includedRequests: value.includedRequests, models: models.length, apiPercentUsed: value.plan?.apiPercentUsed }));
2277
+ return structuredClone(value);
2278
+ }
2279
+
2280
+ async #readModelUsage(cookie, parsed, now, signal) {
2281
+ const start = parsed.billingCycleStart !== undefined ? new Date(parsed.billingCycleStart).getTime() : now - 32 * 24 * 60 * 60 * 1000;
2282
+ const end = parsed.billingCycleEnd !== undefined ? new Date(parsed.billingCycleEnd).getTime() : now;
2283
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return [];
2284
+ try {
2285
+ const aggregated = await this.#request(USAGE_AGGREGATED_URL, cookie, {
2286
+ method: "POST",
2287
+ body: { teamId: 0, startDate: start, endDate: end },
2288
+ signal: this.#mergeSignals(signal, 8_000),
2289
+ });
2290
+ return parseModelAggregations(aggregated);
2291
+ } catch (error) {
2292
+ this.#log("warn", "aggregated model usage unavailable: %s", error instanceof Error ? error.message : error);
2293
+ return [];
2294
+ }
2295
+ }
2296
+
2297
+ clear() {
2298
+ this.#cache = { at: 0, value: undefined };
2299
+ }
2300
+ }
2301
+ //#endregion
2302
+
2303
+ //#region adapter
2304
+ const TOOL_REJECT_REASON =
2305
+ "Tool not available in this environment. Use the MCP tools provided instead.";
2306
+
2307
+ /** Block indices are fixed per type so the assembler keeps blocks distinct. */
2308
+ const TEXT_BLOCK_INDEX = 0;
2309
+ const REASONING_BLOCK_INDEX = 1;
2310
+ const TOOL_BLOCK_INDEX = 2;
2311
+
2312
+ /**
2313
+ * DSH `LlmAdapter` for the Cursor subscription. Normal turns use Cursor
2314
+ * checkpoints; MCP tool calls keep their HTTP/2 Run alive across DSH steps so
2315
+ * the following tool-result can resume the exact pending exec with mcpResult.
2316
+ */
2317
+ export class CursorAdapter extends LlmAdapter {
2318
+ constructor(options) {
2319
+ super();
2320
+ this.auth = options.auth;
2321
+ this.fetchModels = options.fetchModels ?? fetchUsableModels;
2322
+ this.fallbackModels = options.fallbackModels ?? FALLBACK_MODELS;
2323
+ this.idleTimeoutMs = options.idleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS;
2324
+ this.progressTimeoutMs = options.progressTimeoutMs ?? STREAM_PROGRESS_TIMEOUT_MS;
2325
+ this.idleCheckIntervalMs = options.idleCheckIntervalMs ?? 15000;
2326
+ this.modelsCacheTtlMs = options.modelsCacheTtlMs ?? 5 * 60 * 1000;
2327
+ this.sessionStateTtlMs = options.sessionStateTtlMs ?? SESSION_STATE_TTL_MS;
2328
+ const fallbackSettings = resolveCursorSettings({
2329
+ maxToolRounds: options.maxToolRounds,
2330
+ retryCount: options.retryCount,
2331
+ retryIntervalMs: options.retryIntervalMs,
2332
+ retryHttpStatusCodes: options.retryHttpStatusCodes,
2333
+ });
2334
+ this.settings = options.settings ?? (() => fallbackSettings);
2335
+ this.createAgentRun = options.createAgentRun ?? ((access) => new AgentRun(access));
2336
+ this.sleep = options.sleep ?? abortableDelay;
2337
+ this.now = options.now ?? Date.now;
2338
+ this.#modelsCache = { at: 0, models: undefined };
2339
+ }
2340
+
2341
+ #modelsCache;
2342
+ /** Persisted conversation checkpoints + referenced blobs per DSH session id. */
2343
+ #sessions = new Map();
2344
+
2345
+ #evictStaleSessions() {
2346
+ const now = this.now();
2347
+ for (const [key, state] of this.#sessions) {
2348
+ if (now - (state.lastAccessMs ?? now) <= this.sessionStateTtlMs) continue;
2349
+ state.bridge?.run.close();
2350
+ this.#sessions.delete(key);
2351
+ }
2352
+ }
2353
+
2354
+ async #discoverModels(force = false) {
2355
+ const now = this.now();
2356
+ if (!force && this.#modelsCache.models !== undefined && now - this.#modelsCache.at < this.modelsCacheTtlMs) {
2357
+ return this.#modelsCache.models;
2358
+ }
2359
+ let models;
2360
+ try {
2361
+ const access = await this.auth.accessToken();
2362
+ models = await this.fetchModels(access);
2363
+ } catch {
2364
+ models = [];
2365
+ }
2366
+ const source = models.length > 0 ? models : this.fallbackModels;
2367
+ const result = source.map((model) => ({
2368
+ id: model.id,
2369
+ name: model.name || model.id,
2370
+ inputModalities: ["text"],
2371
+ }));
2372
+ this.#modelsCache = { at: now, models: result };
2373
+ return result;
2374
+ }
2375
+
2376
+ providerInfo(provider) {
2377
+ return { id: provider, name: "Cursor subscription" };
2378
+ }
2379
+
2380
+ async listModels(provider) {
2381
+ const models = await this.#discoverModels();
2382
+ return models.map((model) => ({ provider, ...model }));
2383
+ }
2384
+
2385
+ /** RPC-facing model listing; `force` bypasses the discovery cache. */
2386
+ async listModelsForRpc({ force = false, signal } = {}) {
2387
+ const models = await this.#discoverModels(force);
2388
+ if (signal?.aborted) throw new LlmError("Cursor model listing aborted", "ABORTED");
2389
+ return models;
2390
+ }
2391
+
2392
+ async resolveModel(provider, model, _signal) {
2393
+ let name = model;
2394
+ try {
2395
+ const models = await this.#discoverModels();
2396
+ const found = models.find((entry) => entry.id === model);
2397
+ if (found !== undefined) name = found.name;
2398
+ } catch {}
2399
+ return {
2400
+ provider,
2401
+ id: model,
2402
+ name,
2403
+ inputModalities: ["text"],
2404
+ context: { contextWindow: DEFAULT_CONTEXT_WINDOW },
2405
+ defaultMaxTokens: DEFAULT_MAX_TOKENS,
2406
+ };
2407
+ }
2408
+
2409
+ async *stream(options) {
2410
+ const consumer = new AbortController();
2411
+ const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
2412
+ let run;
2413
+ let abortRun;
2414
+ let bridgeStored = false;
2415
+ // Persisted conversation state per session: the server's checkpoint,
2416
+ // referenced blobs, and (while an MCP tool runs) the live HTTP/2 bridge.
2417
+ const sessionKey = options.sessionId === undefined ? undefined : String(options.sessionId);
2418
+ this.#evictStaleSessions();
2419
+ const prior = sessionKey === undefined ? undefined : this.#sessions.get(sessionKey);
2420
+ const persisted = {
2421
+ checkpoint: prior?.checkpoint === undefined ? undefined : Uint8Array.from(prior.checkpoint),
2422
+ blobs: new Map(prior?.blobs ?? []),
2423
+ };
2424
+ try {
2425
+ if (options.stop !== undefined) throw new LlmError("cursor-subscription does not support GenerateOptions.stop", "UNSUPPORTED_OPTION");
2426
+ const requestSettings = resolveCursorSettings(this.settings());
2427
+ const mcpTools = (options.tools ?? []).map((tool) =>
2428
+ encodeMcpToolDefinition({
2429
+ name: tool.name,
2430
+ description: tool.description,
2431
+ inputSchema: encodeValue(tool.parameters ?? {}),
2432
+ }),
2433
+ );
2434
+ const toolResults = collectToolResults(options);
2435
+ const liveBridge = prior?.bridge;
2436
+ const canResume = liveBridge !== undefined && !liveBridge.run.finished && !liveBridge.run.stream?.destroyed && toolResults.length > 0;
2437
+ let toolRoundCount = canResume ? (liveBridge.toolRoundCount ?? 0) : 0;
2438
+ let blobStore;
2439
+ if (canResume) {
2440
+ run = liveBridge.run;
2441
+ blobStore = persisted.blobs;
2442
+ abortRun = () => run.abort(new Error("Cursor request aborted by caller"));
2443
+ upstream.addEventListener("abort", abortRun, { once: true });
2444
+ if (upstream.aborted) abortRun();
2445
+ for (const pending of liveBridge.pendingExecs) {
2446
+ const result = toolResults.find((entry) => entry.toolCallId === pending.toolCallId);
2447
+ const payload = result === undefined
2448
+ ? encodeMcpError("Tool result not provided")
2449
+ : encodeMcpResult(result);
2450
+ if (!run.writeMessage(encodeExecClientMessageEnvelope(encodeExecClientMessage(pending.id, pending.execId, 11, payload)))) {
2451
+ throw new Error("Cursor tool continuation bridge closed before accepting the result");
2452
+ }
2453
+ }
2454
+ } else {
2455
+ if (liveBridge !== undefined) {
2456
+ liveBridge.run.close();
2457
+ // A checkpoint paused on an MCP call cannot safely accept a normal
2458
+ // user action. Fall back to textual cold-start reconstruction.
2459
+ persisted.checkpoint = undefined;
2460
+ persisted.blobs.clear();
2461
+ }
2462
+ const access = await this.auth.accessToken({ signal: upstream });
2463
+ const built = buildRunPayload(options, options.model, persisted);
2464
+ blobStore = built.blobStore;
2465
+ let retriesUsed = 0;
2466
+ for (;;) {
2467
+ run = this.createAgentRun(access);
2468
+ await run.start();
2469
+ abortRun = () => run.abort(new Error("Cursor request aborted by caller"));
2470
+ upstream.addEventListener("abort", abortRun, { once: true });
2471
+ if (upstream.aborted) abortRun();
2472
+ if (!run.writeMessage(built.payload)) throw new Error("Cursor agent bridge closed before accepting the request");
2473
+ const status = await run.waitForResponse(this.idleTimeoutMs);
2474
+ if (isSuccessfulAgentResponse(status, run.responseContentType)) {
2475
+ run.startHeartbeat();
2476
+ break;
2477
+ }
2478
+ upstream.removeEventListener("abort", abortRun);
2479
+ abortRun = undefined;
2480
+ run.close();
2481
+ if (status === 200) {
2482
+ throw new LlmError(
2483
+ `Cursor agent returned an unexpected content type: ${run.responseContentType ?? "missing"}`,
2484
+ "CURSOR_PROTOCOL",
2485
+ );
2486
+ }
2487
+ if (!shouldRetryHttpStatus(status, retriesUsed, requestSettings)) {
2488
+ const message = `Cursor agent returned HTTP ${status}`;
2489
+ throw new LlmError(message, classifyCursorError(message));
2490
+ }
2491
+ retriesUsed++;
2492
+ await this.sleep(requestSettings.retryIntervalMs, upstream);
2493
+ }
2494
+ }
2495
+
2496
+ const started = this.now();
2497
+ let lastActivity = started;
2498
+ let lastProgress = started;
2499
+ const idleCheck = setInterval(() => {
2500
+ const now = this.now();
2501
+ if (now - lastActivity > this.idleTimeoutMs) {
2502
+ run.abort(new Error("Cursor stream idle timeout"));
2503
+ } else if (now - lastProgress > this.progressTimeoutMs) {
2504
+ run.abort(new Error(`Cursor stream progress timeout: no content for ${this.progressTimeoutMs}ms`));
2505
+ }
2506
+ }, this.idleCheckIntervalMs);
2507
+ idleCheck.unref?.();
2508
+
2509
+ let emittedToolCall = false;
2510
+ let toolCallBuffer = undefined;
2511
+ let textOutput = "";
2512
+ let outputTokens = 0;
2513
+ let streamClosed = false;
2514
+ let terminalErrorEmitted = false;
2515
+ let toolCallPending = false;
2516
+ let toolRoundCounted = false;
2517
+ const pendingExecs = [];
2518
+
2519
+ try {
2520
+ for (;;) {
2521
+ const frame = await run.frames.next();
2522
+ if (frame === undefined) break;
2523
+ lastActivity = this.now();
2524
+ if ((frame.flags & CONNECT_END_STREAM_FLAG) !== 0) {
2525
+ const end = parseEndStream(frame.payload);
2526
+ if (end !== undefined) {
2527
+ terminalErrorEmitted = true;
2528
+ yield {
2529
+ type: "finish",
2530
+ reason: {
2531
+ kind: "error",
2532
+ failure: {
2533
+ message: end.message,
2534
+ code: classifyCursorError(`${end.code} ${end.debugCode ?? ""} ${end.message}`),
2535
+ },
2536
+ },
2537
+ };
2538
+ streamClosed = true;
2539
+ break;
2540
+ }
2541
+ break;
2542
+ }
2543
+ const message = decodeAgentServerMessage(frame.payload);
2544
+ // Heartbeats keep the connection alive but are not content
2545
+ // progress; a server that only pings us (e.g. after an empty
2546
+ // partialToolCall placeholder) must still hit the stall timeout
2547
+ // instead of leaving the UI spinning forever.
2548
+ if (!(message.case === "interactionUpdate" && message.value?.type === "heartbeat")) {
2549
+ lastProgress = this.now();
2550
+ }
2551
+ if (message.case === "conversationCheckpointUpdate") {
2552
+ // Reader.bytes() returns a view into the frame buffer; copy it before
2553
+ // retaining it for a later request.
2554
+ persisted.checkpoint = Uint8Array.from(message.value);
2555
+ if (toolCallPending) {
2556
+ // Cursor emits this checkpoint after mcpArgs. Keep the same Run
2557
+ // alive; the next DSH step resumes it with McpResult field 11.
2558
+ streamClosed = true;
2559
+ break;
2560
+ }
2561
+ } else if (message.case === "interactionUpdate") {
2562
+ const update = message.value;
2563
+ if (update.type === "textDelta") {
2564
+ if (update.text.length > 0) {
2565
+ textOutput += update.text;
2566
+ yield { type: "text-delta", index: TEXT_BLOCK_INDEX, text: update.text };
2567
+ }
2568
+ } else if (update.type === "thinkingDelta") {
2569
+ if (update.text.length > 0) {
2570
+ yield { type: "reasoning-delta", index: REASONING_BLOCK_INDEX, text: update.text };
2571
+ }
2572
+ } else if (update.type === "tokenDelta") {
2573
+ if (Number.isFinite(update.tokens) && update.tokens > 0) outputTokens = update.tokens;
2574
+ } else if (update.type === "partialToolCall") {
2575
+ if (toolCallBuffer === undefined || toolCallBuffer.id !== update.callId) {
2576
+ toolCallBuffer = { id: update.callId, name: "", arguments: "" };
2577
+ yield { type: "block-start", index: TOOL_BLOCK_INDEX, blockType: "tool-call" };
2578
+ yield { type: "tool-call-delta", index: TOOL_BLOCK_INDEX, id: ToolCallId(update.callId), argumentsDelta: "" };
2579
+ }
2580
+ if (update.argsTextDelta.length > 0) {
2581
+ toolCallBuffer.arguments += update.argsTextDelta;
2582
+ yield { type: "tool-call-delta", index: TOOL_BLOCK_INDEX, id: ToolCallId(update.callId), argumentsDelta: update.argsTextDelta };
2583
+ }
2584
+ } else if (update.type === "toolCallStarted" || update.type === "toolCallCompleted" || update.type === "toolCallDelta") {
2585
+ emittedToolCall = true;
2586
+ }
2587
+ } else if (message.case === "kvServerMessage") {
2588
+ const kv = message.value;
2589
+ if (kv.case === "getBlobArgs") {
2590
+ const key = Buffer.from(kv.blobId).toString("hex");
2591
+ const blob = blobStore.get(key);
2592
+ run.writeMessage(encodeKvClientMessage(encodeGetBlobResult(kv.id, blob)));
2593
+ } else if (kv.case === "setBlobArgs") {
2594
+ if (kv.blobId !== undefined && kv.blobData !== undefined) {
2595
+ const key = Buffer.from(kv.blobId).toString("hex");
2596
+ // Reader.bytes() returns views; retain independent copies for both
2597
+ // this run's GET handshake and the next run's checkpoint.
2598
+ const data = Uint8Array.from(kv.blobData);
2599
+ blobStore.set(key, data);
2600
+ persisted.blobs.set(key, data);
2601
+ }
2602
+ // Acknowledge server blob writes; without the reply the
2603
+ // run can stall waiting for the handshake to finish.
2604
+ run.writeMessage(encodeKvClientMessage(encodeSetBlobResult(kv.id)));
2605
+ }
2606
+ } else if (message.case === "execServerMessage") {
2607
+ const exec = message.value;
2608
+ if (exec.case === "requestContextArgs") {
2609
+ run.writeMessage(encodeExecClientMessageEnvelope(encodeExecClientMessage(exec.id, exec.execId, 10, encodeRequestContextResult(mcpTools))));
2610
+ } else if (exec.case === "mcpArgs") {
2611
+ if (!toolRoundCounted) {
2612
+ toolRoundCount++;
2613
+ toolRoundCounted = true;
2614
+ }
2615
+ if (toolRoundCount > requestSettings.maxToolRounds) {
2616
+ throw new LlmError(
2617
+ `Cursor tool-call safety limit reached after ${requestSettings.maxToolRounds} rounds. The run was stopped to prevent an infinite loop; send a new message to continue if needed.`,
2618
+ "TOOL_LIMIT",
2619
+ );
2620
+ }
2621
+ emittedToolCall = true;
2622
+ // decodeExecServerMessage nests the parsed McpArgs under
2623
+ // `exec.args`: { name, toolCallId, providerIdentifier, toolName, args }.
2624
+ const mcp = exec.args ?? {};
2625
+ const args = {};
2626
+ for (const [key, value] of Object.entries(mcp.args ?? {})) {
2627
+ try {
2628
+ args[key] = decodeValue(value);
2629
+ } catch {
2630
+ args[key] = null;
2631
+ }
2632
+ }
2633
+ const id = mcp.toolCallId || exec.execId || crypto.randomUUID();
2634
+ const name = mcp.toolName || mcp.name;
2635
+ yield { type: "block-start", index: TOOL_BLOCK_INDEX, blockType: "tool-call" };
2636
+ yield {
2637
+ type: "tool-call-delta",
2638
+ index: TOOL_BLOCK_INDEX,
2639
+ id: ToolCallId(id),
2640
+ name,
2641
+ argumentsDelta: JSON.stringify(args),
2642
+ };
2643
+ yield {
2644
+ type: "block-end",
2645
+ index: TOOL_BLOCK_INDEX,
2646
+ block: {
2647
+ type: "tool-call",
2648
+ id: ToolCallId(id),
2649
+ name,
2650
+ arguments: JSON.stringify(args),
2651
+ },
2652
+ };
2653
+ pendingExecs.push({ id: exec.id, execId: exec.execId, toolCallId: id });
2654
+ toolCallPending = true;
2655
+ // Do not close the Run. Cursor sends blob writes + a checkpoint
2656
+ // immediately after mcpArgs; once captured, return tool-calls to DSH
2657
+ // while preserving this bridge for the result.
2658
+ streamClosed = true;
2659
+ } else {
2660
+ const reply = rejectionFor(exec);
2661
+ if (reply !== undefined) {
2662
+ run.writeMessage(encodeExecClientMessageEnvelope(encodeExecClientMessage(exec.id, exec.execId, reply.field, reply.payload)));
2663
+ }
2664
+ }
2665
+ }
2666
+ }
2667
+ } finally {
2668
+ clearInterval(idleCheck);
2669
+ }
2670
+
2671
+ if (!terminalErrorEmitted && !toolCallPending) {
2672
+ const textToolCalls = parseTextToolCalls(textOutput, options.tools ?? []);
2673
+ for (let index = 0; index < textToolCalls.length; index++) {
2674
+ const call = textToolCalls[index];
2675
+ const blockIndex = TOOL_BLOCK_INDEX + index;
2676
+ yield { type: "block-start", index: blockIndex, blockType: "tool-call" };
2677
+ yield {
2678
+ type: "tool-call-delta",
2679
+ index: blockIndex,
2680
+ id: ToolCallId(call.id),
2681
+ name: call.name,
2682
+ argumentsDelta: call.arguments,
2683
+ };
2684
+ yield {
2685
+ type: "block-end",
2686
+ index: blockIndex,
2687
+ block: { type: "tool-call", id: ToolCallId(call.id), name: call.name, arguments: call.arguments },
2688
+ };
2689
+ }
2690
+ if (textToolCalls.length > 0) {
2691
+ emittedToolCall = true;
2692
+ streamClosed = true;
2693
+ }
2694
+ }
2695
+
2696
+ const liveToolBridge = toolCallPending && pendingExecs.length > 0 && !run.finished;
2697
+ if (sessionKey !== undefined && (persisted.checkpoint !== undefined || liveToolBridge)) {
2698
+ persisted.lastAccessMs = this.now();
2699
+ if (liveToolBridge) {
2700
+ persisted.bridge = { run, pendingExecs, toolRoundCount };
2701
+ bridgeStored = true;
2702
+ }
2703
+ // Refresh insertion order for simple LRU-style eviction. A checkpoint
2704
+ // may reference every retained blob, so prune whole sessions rather than
2705
+ // individual blobs. Close an evicted live bridge to avoid socket leaks.
2706
+ this.#sessions.delete(sessionKey);
2707
+ this.#sessions.set(sessionKey, persisted);
2708
+ while (this.#sessions.size > 100) {
2709
+ const oldest = this.#sessions.keys().next().value;
2710
+ if (oldest === undefined) break;
2711
+ this.#sessions.get(oldest)?.bridge?.run.close();
2712
+ this.#sessions.delete(oldest);
2713
+ }
2714
+ }
2715
+
2716
+ if (!terminalErrorEmitted) {
2717
+ yield { type: "usage", usage: { inputTokens: 0, outputTokens } };
2718
+ if (streamClosed && emittedToolCall) {
2719
+ yield { type: "finish", reason: { kind: "tool-calls" } };
2720
+ } else if (run.trailers && Number(run.trailers["grpc-status"]) > 0) {
2721
+ const message = run.trailers["grpc-message"] ?? "Cursor agent run failed";
2722
+ yield { type: "finish", reason: { kind: "error", failure: { message, code: classifyCursorError(message) } } };
2723
+ } else {
2724
+ yield { type: "finish", reason: { kind: "stop" } };
2725
+ }
2726
+ }
2727
+ } catch (error) {
2728
+ if (upstream.aborted) {
2729
+ yield {
2730
+ type: "finish",
2731
+ reason: { kind: "aborted", failure: { message: "Cursor request aborted by caller", code: "ABORTED" } },
2732
+ };
2733
+ } else if (error instanceof LlmError) {
2734
+ yield { type: "finish", reason: { kind: "error", failure: { message: error.message, code: error.code } } };
2735
+ } else {
2736
+ const message = error instanceof Error ? error.message : String(error);
2737
+ yield { type: "finish", reason: { kind: "error", failure: { message, code: classifyCursorError(message) } } };
2738
+ }
2739
+ } finally {
2740
+ if (abortRun !== undefined) upstream.removeEventListener("abort", abortRun);
2741
+ consumer.abort("cursor stream consumer stopped");
2742
+ if (!bridgeStored) run?.close();
2743
+ }
2744
+ }
2745
+ }
2746
+
2747
+ /**
2748
+ * Parse a Connect end-stream frame into a structured error. Cursor's agent
2749
+ * errors carry a `debug` payload with a stable code and human-readable
2750
+ * `title`/`detail`; extracting those gives the user the real reason instead of
2751
+ * a bare `resource_exhausted`.
2752
+ * @returns {undefined | {code: string, debugCode?: string, message: string}}
2753
+ */
2754
+ export function parseEndStream(payload) {
2755
+ try {
2756
+ const json = JSON.parse(new TextDecoder().decode(payload));
2757
+ const error = json?.error;
2758
+ if (!error) return undefined;
2759
+ const code = typeof error.code === "string" ? error.code : "unknown";
2760
+ let debugCode;
2761
+ let title;
2762
+ let detail;
2763
+ if (Array.isArray(error.details)) {
2764
+ for (const entry of error.details) {
2765
+ if (!record(entry) || !record(entry.debug)) continue;
2766
+ if (typeof entry.debug.error === "string") debugCode = entry.debug.error;
2767
+ const details = record(entry.debug.details) ? entry.debug.details : undefined;
2768
+ if (details !== undefined) {
2769
+ if (typeof details.title === "string") title = details.title;
2770
+ if (typeof details.detail === "string") detail = details.detail;
2771
+ }
2772
+ }
2773
+ }
2774
+ const fallback = typeof error.message === "string" && error.message.length > 0 ? error.message : undefined;
2775
+ const message = [title, detail].filter((part) => typeof part === "string" && part.length > 0).join(" ")
2776
+ || (debugCode !== undefined ? `Cursor: ${debugCode}` : undefined)
2777
+ || (fallback !== undefined ? `Cursor agent error ${code}: ${fallback}` : `Cursor agent error ${code}`);
2778
+ return { code, debugCode, message };
2779
+ } catch {
2780
+ return undefined;
2781
+ }
2782
+ }
2783
+
2784
+ export function classifyCursorError(message) {
2785
+ if (/\b(?:401|403)\b|unauth|invalid.*(?:key|token|credential)/i.test(message)) return "AUTH";
2786
+ if (/rate.?limit|\b429\b|quota|resource_exhausted|spend.?limit|usage limit|exceeded/i.test(message)) return "RATE_LIMIT";
2787
+ if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST";
2788
+ if (/\b408\b|timeout|timed out/i.test(message)) return "TIMEOUT";
2789
+ if (/\b5\d\d\b|internal/i.test(message)) return "SERVER";
2790
+ if (/\b(?:network|connection|socket|fetch|ECONN|http2)/i.test(message)) return "TRANSPORT";
2791
+ return "CURSOR_ERROR";
2792
+ }
2793
+
2794
+ /** Build the exec-client reply for a native Cursor tool we reject. */
2795
+ export function rejectionFor(exec) {
2796
+ switch (exec.case) {
2797
+ case "readArgs":
2798
+ return { field: 7, payload: encodeReadRejected(exec.path, TOOL_REJECT_REASON) };
2799
+ case "lsArgs":
2800
+ return { field: 8, payload: encodeLsRejected(exec.path, TOOL_REJECT_REASON) };
2801
+ case "grepArgs":
2802
+ return { field: 5, payload: encodeGrepError(TOOL_REJECT_REASON) };
2803
+ case "writeArgs":
2804
+ return { field: 3, payload: encodeWriteRejected(exec.path, TOOL_REJECT_REASON) };
2805
+ case "deleteArgs":
2806
+ return { field: 4, payload: encodeDeleteRejected(exec.path, TOOL_REJECT_REASON) };
2807
+ case "shellArgs":
2808
+ // Non-streaming shell exec replies with ShellResult (field 2).
2809
+ return { field: 2, payload: encodeShellRejectedResult(undefined, undefined, TOOL_REJECT_REASON) };
2810
+ case "shellStreamArgs":
2811
+ // Streaming shell exec replies with ShellStream (field 14); a
2812
+ // ShellResult here leaves the server waiting for the stream.
2813
+ return { field: 14, payload: encodeShellStreamRejected(undefined, undefined, TOOL_REJECT_REASON) };
2814
+ case "backgroundShellSpawnArgs":
2815
+ return { field: 16, payload: encodeBackgroundShellRejectedResult(undefined, undefined, TOOL_REJECT_REASON) };
2816
+ case "fetchArgs":
2817
+ return { field: 20, payload: encodeFetchError(exec.url, TOOL_REJECT_REASON) };
2818
+ case "writeShellStdinArgs":
2819
+ return { field: 23, payload: encodeWriteShellStdinError(TOOL_REJECT_REASON) };
2820
+ case "diagnosticsArgs":
2821
+ return { field: 9, payload: encodeDiagnosticsResult() };
2822
+ case "recordScreenArgs":
2823
+ return { field: 21, payload: encodeMcpError("Screen recording is not available") };
2824
+ case "computerUseArgs":
2825
+ return { field: 22, payload: encodeMcpError("Computer use is not available") };
2826
+ case "listMcpResourcesExecArgs":
2827
+ return { field: 17, payload: encodeMcpError("MCP resources are not available") };
2828
+ case "readMcpResourceExecArgs":
2829
+ return { field: 18, payload: encodeMcpError("MCP resources are not available") };
2830
+ default:
2831
+ return undefined;
2832
+ }
2833
+ }
2834
+ //#endregion
2835
+
2836
+ //#region apply
2837
+ export function apply(ctx, config = {}) {
2838
+ let currentSettings = () => config;
2839
+ ctx.get("settings").installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
2840
+ setSource: (source) => {
2841
+ currentSettings = source;
2842
+ },
2843
+ onChange: () => {},
2844
+ validate: (value) => {
2845
+ resolveCursorSettings(value);
2846
+ },
2847
+ });
2848
+ const readSettings = () => resolveCursorSettings(currentSettings());
2849
+ const readSettingsView = () => {
2850
+ const value = readSettings();
2851
+ const descriptor = ctx.get("settings")?.describe({ redactSecrets: true })
2852
+ .find((entry) => entry.ns === SETTINGS_NAMESPACE);
2853
+ return { ...value, revision: descriptor?.revision ?? 0 };
2854
+ };
2855
+ const settingsController = {
2856
+ read: readSettingsView,
2857
+ update: async (patch, expectedRevision) => {
2858
+ resolveCursorSettings({ ...readSettings(), ...patch });
2859
+ const service = ctx.get("settings");
2860
+ if (service === undefined) throw new Error("Cursor settings storage is unavailable");
2861
+ await service.update(SETTINGS_NAMESPACE, patch, expectedRevision);
2862
+ return readSettingsView();
2863
+ },
2864
+ };
2865
+ const store = new CursorCredentialStore(ctx.credentials, CREDENTIAL_REF);
2866
+ const auth = new CursorAuthService(store, { logger: ctx.logger });
2867
+ const adapter = new CursorAdapter({ auth, settings: readSettings });
2868
+ ctx.llm.registerAdapter([PROVIDER], adapter);
2869
+ const usageReader = new CursorUsageReader(auth, { logger: ctx.logger });
2870
+
2871
+ const coordinator = new CursorLoginCoordinator(auth, { logger: ctx.logger });
2872
+ const handler = createCursorRpcHandler(coordinator, {
2873
+ openExternal: openCursorAuthUrl,
2874
+ usageReader,
2875
+ modelsProvider: adapter,
2876
+ settings: settingsController,
2877
+ });
2878
+ ctx.effect(
2879
+ () => ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" }),
2880
+ "cursor-subscription: loopback account RPC",
2881
+ );
2882
+ }
2883
+ //#endregion