dsh-subscriptions 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +9 -21
  2. package/lib/auth/rpc.d.ts +2 -20
  3. package/lib/auth/rpc.js +3 -49
  4. package/lib/client/ProviderAccountManager.d.ts +1 -1
  5. package/lib/client/ProviderAccountManager.js +86 -110
  6. package/lib/client/ProviderModelEditor.d.ts +1 -1
  7. package/lib/client/ProviderModelEditor.js +2 -6
  8. package/lib/client/SubscriptionUsageBadge.d.ts +8 -2
  9. package/lib/client/SubscriptionUsageBadge.js +24 -17
  10. package/lib/client/SubscriptionsSection.js +1 -2
  11. package/lib/client/locales.d.ts +2 -32
  12. package/lib/client/locales.js +2 -32
  13. package/lib/client/model-filter.js +1 -1
  14. package/lib/client/subscription-card-styles.d.ts +1 -1
  15. package/lib/client/subscription-card-styles.js +1 -1
  16. package/lib/client.js +234 -518
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +208 -3305
  19. package/lib/providers/claude.d.ts +3 -1
  20. package/lib/providers/claude.js +2 -2
  21. package/lib/providers/ollama-settings.d.ts +1 -1
  22. package/lib/providers/ollama-settings.js +1 -1
  23. package/lib/providers/pool-usage.d.ts +10 -9
  24. package/lib/providers/pool-usage.js +30 -19
  25. package/lib/providers/pool.d.ts +6 -6
  26. package/lib/providers/pool.js +24 -12
  27. package/package.json +1 -7
  28. package/lib/client/CursorCard.d.ts +0 -9
  29. package/lib/client/CursorCard.js +0 -151
  30. package/lib/providers/cursor-adapter.d.ts +0 -50
  31. package/lib/providers/cursor-adapter.js +0 -304
  32. package/lib/providers/cursor-auth.d.ts +0 -43
  33. package/lib/providers/cursor-auth.js +0 -221
  34. package/lib/providers/cursor-model-settings.d.ts +0 -15
  35. package/lib/providers/cursor-model-settings.js +0 -60
  36. package/lib/providers/cursor-usage.d.ts +0 -9
  37. package/lib/providers/cursor-usage.js +0 -131
  38. package/vendor/cursor/LICENSE +0 -21
  39. package/vendor/cursor/index.d.ts +0 -18
  40. package/vendor/cursor/index.js +0 -3574
  41. package/vendor/cursor/proto.js +0 -254
@@ -1,3574 +0,0 @@
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 { appendFileSync, readFileSync } from "node:fs";
21
- import { homedir } from "node:os";
22
- import { join } from "node:path";
23
- import http2 from "node:http2";
24
- import http from "node:http";
25
- import https from "node:https";
26
- import { spawn } from "node:child_process";
27
- import { gunzipSync } from "node:zlib";
28
- import { encodeValue, decodeValue, Reader, Writer } from "./proto.js";
29
-
30
- /**
31
- * Read this package's own version from the manifest beside `lib/`.
32
- * @returns the manifest version, or undefined when it cannot be read.
33
- */
34
- function readVersion() {
35
- try {
36
- return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
37
- } catch {
38
- /* An unreadable manifest leaves the version unknown instead of failing the plugin. */
39
- return undefined;
40
- }
41
- }
42
- /** Build version the settings panel prints; undefined when the manifest is unreadable. */
43
- export const VERSION = readVersion();
44
-
45
- //#region native fetch
46
- /**
47
- * DSH web replaces the ambient `fetch` with an MCP `createFetchWithInit`
48
- * wrapper whose `baseFetch` is undefined in the host process, so any consumer
49
- * of the global `fetch` throws `baseFetch is not a function`. Cursor usage /
50
- * refresh / model discovery go through HTTP, so this module uses Node's own
51
- * `http`/`https` to talk to the upstream directly instead of trusting the
52
- * host-injected `fetch`. It exposes the small Fetch subset these callers use.
53
- */
54
- export async function nativeFetch(url, init = {}) {
55
- const target = new URL(url);
56
- const client = target.protocol === "https:" ? https : http;
57
- const method = (init.method ?? "GET").toUpperCase();
58
- const body = init.body === undefined ? undefined : isBytes(init.body) ? Buffer.from(init.body) : String(init.body);
59
-
60
- const request = (u, redirect) =>
61
- new Promise((resolve, reject) => {
62
- const req = client.request(
63
- u,
64
- {
65
- method,
66
- headers: sanitizeHeaders(init.headers),
67
- signal: init.signal,
68
- },
69
- (res) => {
70
- const chunks = [];
71
- res.on("data", (chunk) => chunks.push(chunk));
72
- res.on("end", () => {
73
- const data = Buffer.concat(chunks);
74
- const status = res.statusCode ?? 0;
75
- if ((redirect === "error" || redirect === "manual") && status >= 300 && status < 400) {
76
- reject(new Error(`HTTP ${status} from ${u.origin}${u.pathname}`));
77
- return;
78
- }
79
- const location = res.headers.location;
80
- if ((redirect ?? "follow") === "follow" && location && status >= 300 && status < 400) {
81
- const next = new URL(location, u);
82
- resolve({ redirect: next });
83
- return;
84
- }
85
- resolve({
86
- response: makeResponse(status, res, data),
87
- });
88
- });
89
- },
90
- );
91
- req.on("error", reject);
92
- if (body !== undefined) req.write(body);
93
- req.end();
94
- });
95
-
96
- let u = target;
97
- for (let hops = 0; ; hops++) {
98
- const result = await request(u, init.redirect);
99
- if (result.redirect !== undefined) {
100
- if (hops >= 6) throw new Error(`too many redirects from ${target.href}`);
101
- u = result.redirect;
102
- continue;
103
- }
104
- return result.response;
105
- }
106
- }
107
-
108
- function isBytes(value) {
109
- return value instanceof Uint8Array || (typeof value !== "string" && ArrayBuffer.isView(value));
110
- }
111
-
112
- function sanitizeHeaders(headers) {
113
- if (headers === undefined) return undefined;
114
- const out = {};
115
- for (const [key, value] of Object.entries(headers)) {
116
- if (value === undefined) continue;
117
- out[key] = value;
118
- }
119
- return out;
120
- }
121
-
122
- function makeResponse(status, res, data) {
123
- const text = async () => data.toString("utf8");
124
- return {
125
- ok: status >= 200 && status < 300,
126
- status,
127
- statusText: res.statusMessage ?? "",
128
- headers: {
129
- get: (name) => res.headers[String(name).toLowerCase()] ?? null,
130
- },
131
- text: async () => text(),
132
- json: async () => JSON.parse(await text()),
133
- arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
134
- };
135
- }
136
- //#endregion
137
-
138
- //#region constants
139
- export const name = "cursor-subscription";
140
- export const inject = ["llm", "credentials", "connection"];
141
-
142
- export const PROVIDER = "cursor-subscription";
143
- export const CREDENTIAL_REF = credentialRef("CURSOR_SUBSCRIPTION_OAUTH");
144
- export const CHANNEL = "/cursor-subscription";
145
- /** Connection registry options: accept the account channel from loopback Hosts only. */
146
- const LOOPBACK_RPC = Object.freeze({ authority: "loopback" });
147
-
148
- export const CURSOR_BASE_URL = "https://api2.cursor.sh";
149
- export const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
150
- export const CURSOR_AUTH_ORIGIN = "https://cursor.com";
151
- export const CURSOR_POLL_PATH = "/auth/poll";
152
- export const CURSOR_REFRESH_PATH = "/auth/exchange_user_api_key";
153
- export const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run";
154
- export const CURSOR_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
155
-
156
- /** Cursor dashboard usage endpoints (authenticated with the session cookie). */
157
- export const USAGE_API_ORIGIN = "https://cursor.com";
158
- export const USAGE_URL = `${USAGE_API_ORIGIN}/api/usage`;
159
- export const USAGE_SUMMARY_URL = `${USAGE_API_ORIGIN}/api/usage-summary`;
160
- export const USAGE_TEAMS_URL = `${USAGE_API_ORIGIN}/api/dashboard/teams`;
161
- export const USAGE_AGGREGATED_URL = `${USAGE_API_ORIGIN}/api/dashboard/get-aggregated-usage-events`;
162
- export const USAGE_TTL_MS = 60 * 1000;
163
- /** Cap the per-model spend list returned to the settings panel. */
164
- export const MAX_USAGE_MODELS = 20;
165
-
166
- /** Client version reported to the Agent service; bump when Cursor requires it. */
167
- export const CURSOR_CLIENT_VERSION = "cli-2026.02.13-41ac335";
168
- /** Keep-alive heartbeats while an agent run is streaming. */
169
- export const HEARTBEAT_INTERVAL_MS = 5000;
170
- /** How long an agent run may stay completely silent before we abort. */
171
- export const STREAM_IDLE_TIMEOUT_MS = 120 * 1000;
172
- /** How long a run may keep sending heartbeats without any real content. */
173
- export const STREAM_PROGRESS_TIMEOUT_MS = 60 * 1000;
174
- /** Retain checkpoints/live tool bridges for inactive DSH sessions. */
175
- export const SESSION_STATE_TTL_MS = 30 * 60 * 1000;
176
- /** Stop one Cursor Run before an unconstrained agent can loop forever. */
177
- export const MAX_TOOL_ROUNDS = 200;
178
- /**
179
- * How long a tool-call step keeps reading after `mcpArgs` for the frames that
180
- * close the burst (sibling calls, blob writes, checkpoint). Sibling calls of
181
- * one burst arrive within ~100 ms of each other.
182
- */
183
- export const TOOL_CALL_SETTLE_MS = 500;
184
- export const DEFAULT_RETRY_COUNT = 0;
185
- export const DEFAULT_RETRY_INTERVAL_MS = 1000;
186
- export const DEFAULT_RETRY_HTTP_STATUS_CODES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
187
- /**
188
- * Rebuild the Cursor conversation from the DSH history on every step instead of
189
- * resuming the conversation Cursor keeps server-side.
190
- *
191
- * A plugin whose transport re-sends its own message history — as the standard
192
- * function-calling ones do — hands the model the same explicit history at every
193
- * step. Delegating that history to Cursor's server instead is what a Cursor turn
194
- * was observed to re-derive from: the step re-stated its goal, re-read its goal
195
- * and rewrote its plan while its tool calls kept succeeding. Off by default: a
196
- * rebuild re-sends the whole transcript each step, and Cursor has no
197
- * provider-side compaction to bound it.
198
- */
199
- export const DEFAULT_REPLAY_HISTORY_EACH_STEP = false;
200
- /**
201
- * Settings namespace. DSH derives a plugin's settings section from its own
202
- * profile entry, so this has to keep matching the entry id in
203
- * `cordis.patch.yml`: it is the namespace `describe` and `update` are keyed by.
204
- */
205
- export const SETTINGS_NAMESPACE = "cursor-subscription";
206
- /**
207
- * Editable runtime settings. Every field is `.volatile()`: the Loader parses a
208
- * volatile field into a live reference that is updated in place when the
209
- * settings page saves, which is what carries a saved value into a Cursor run
210
- * that is already in flight. On DSH 0.1.7 a Config without a volatile field
211
- * produces no settings section at all, so none of these may lose the marker.
212
- */
213
- export const Config = z.object({
214
- maxToolRounds: z.number().step(1).min(1).max(1000).default(MAX_TOOL_ROUNDS).volatile(),
215
- retryCount: z.number().step(1).min(0).max(10).default(DEFAULT_RETRY_COUNT).volatile(),
216
- retryIntervalMs: z.number().step(1).min(0).max(300_000).default(DEFAULT_RETRY_INTERVAL_MS).volatile(),
217
- retryHttpStatusCodes: z.array(z.number().step(1).min(400).max(599)).default([...DEFAULT_RETRY_HTTP_STATUS_CODES]).volatile(),
218
- replayHistoryEachStep: z.boolean().default(DEFAULT_REPLAY_HISTORY_EACH_STEP).volatile(),
219
- });
220
- /** Refresh the access token this early before its JWT expiry. */
221
- export const REFRESH_AHEAD_MS = 5 * 60 * 1000;
222
- /** Default access-token lifetime when the JWT has no usable exp claim. */
223
- export const DEFAULT_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
224
-
225
- export const DEFAULT_CONTEXT_WINDOW = 200000;
226
- export const DEFAULT_MAX_TOKENS = 64000;
227
-
228
- /**
229
- * Read one Config field. DSH 0.1.7 delivers a `.volatile()` field to the plugin
230
- * as a reference whose `get()` returns the value the settings page last saved —
231
- * the Loader keeps that one object and writes into it, so reading it per call
232
- * is what makes a saved edit visible. Tests and the frozen fallback pass
233
- * ordinary values, so both shapes are accepted here.
234
- * @param value Config field as delivered by the Loader, or a plain value.
235
- * @returns The current plain value.
236
- */
237
- function readConfigField(value) {
238
- return typeof value?.get === "function" ? value.get() : value;
239
- }
240
-
241
- export function resolveCursorSettings(input = {}) {
242
- const maxToolRounds = readConfigField(input.maxToolRounds) ?? MAX_TOOL_ROUNDS;
243
- const retryCount = readConfigField(input.retryCount) ?? DEFAULT_RETRY_COUNT;
244
- const retryIntervalMs = readConfigField(input.retryIntervalMs) ?? DEFAULT_RETRY_INTERVAL_MS;
245
- const retryHttpStatusCodes = readConfigField(input.retryHttpStatusCodes) ?? DEFAULT_RETRY_HTTP_STATUS_CODES;
246
- const replayHistoryEachStep = readConfigField(input.replayHistoryEachStep) ?? DEFAULT_REPLAY_HISTORY_EACH_STEP;
247
- if (!Number.isSafeInteger(maxToolRounds) || maxToolRounds < 1 || maxToolRounds > 1000) {
248
- throw new Error("cursor-subscription: maxToolRounds must be an integer between 1 and 1000");
249
- }
250
- if (!Number.isSafeInteger(retryCount) || retryCount < 0 || retryCount > 10) {
251
- throw new Error("cursor-subscription: retryCount must be an integer between 0 and 10");
252
- }
253
- if (!Number.isSafeInteger(retryIntervalMs) || retryIntervalMs < 0 || retryIntervalMs > 300_000) {
254
- throw new Error("cursor-subscription: retryIntervalMs must be an integer between 0 and 300000");
255
- }
256
- if (!Array.isArray(retryHttpStatusCodes)
257
- || retryHttpStatusCodes.some((status) => !Number.isSafeInteger(status) || status < 400 || status > 599)) {
258
- throw new Error("cursor-subscription: retryHttpStatusCodes must contain only HTTP status integers from 400 to 599");
259
- }
260
- if (new Set(retryHttpStatusCodes).size !== retryHttpStatusCodes.length) {
261
- throw new Error("cursor-subscription: retryHttpStatusCodes must not contain duplicates");
262
- }
263
- if (typeof replayHistoryEachStep !== "boolean") {
264
- throw new Error("cursor-subscription: replayHistoryEachStep must be a boolean");
265
- }
266
- return Object.freeze({
267
- maxToolRounds,
268
- retryCount,
269
- retryIntervalMs,
270
- retryHttpStatusCodes: Object.freeze([...retryHttpStatusCodes]),
271
- replayHistoryEachStep,
272
- });
273
- }
274
-
275
- export function shouldRetryHttpStatus(status, retriesUsed, settings) {
276
- return retriesUsed < settings.retryCount && settings.retryHttpStatusCodes.includes(status);
277
- }
278
-
279
- export function isSuccessfulAgentResponse(status, contentType) {
280
- return status === 200 && /^application\/connect\+proto(?:\s*;|\s*$)/i.test(contentType ?? "");
281
- }
282
-
283
- function abortableDelay(ms, signal) {
284
- if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Cursor retry aborted"));
285
- if (ms === 0) return Promise.resolve();
286
- return new Promise((resolve, reject) => {
287
- const timer = setTimeout(done, ms);
288
- timer.unref?.();
289
- function done() {
290
- signal.removeEventListener("abort", aborted);
291
- resolve();
292
- }
293
- function aborted() {
294
- clearTimeout(timer);
295
- signal.removeEventListener("abort", aborted);
296
- reject(signal.reason ?? new Error("Cursor retry aborted"));
297
- }
298
- signal.addEventListener("abort", aborted, { once: true });
299
- });
300
- }
301
- //#endregion
302
-
303
- //#region model ordering
304
- /**
305
- * Cursor's `GetUsableModels` answers in its own order (newest family first),
306
- * and that order moves between releases, so the picker would reshuffle on its
307
- * own. Sort by display name instead, with the id as a tiebreak.
308
- *
309
- * The collation is pinned to `"en"` so one build lists the same order on every
310
- * host locale, and `numeric` so families keep their natural sequence
311
- * (`GPT-5` before `GPT-10`, `Composer 2` after `Composer 1.5`).
312
- */
313
- const MODEL_NAME_COLLATOR = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
314
-
315
- /** Order model entries by name then id, without mutating the input. */
316
- export function sortModelsByName(models) {
317
- return [...models].sort((a, b) => {
318
- const byName = MODEL_NAME_COLLATOR.compare(a?.name ?? "", b?.name ?? "");
319
- if (byName !== 0) return byName;
320
- return MODEL_NAME_COLLATOR.compare(a?.id ?? "", b?.id ?? "");
321
- });
322
- }
323
- //#endregion
324
-
325
- //#region credential store
326
- const clone = (value) => (value === undefined ? undefined : structuredClone(value));
327
-
328
- function assertOAuthCredential(value) {
329
- if (value === undefined) return undefined;
330
- if (
331
- value === null ||
332
- typeof value !== "object" ||
333
- value.type !== "oauth" ||
334
- typeof value.access !== "string" ||
335
- value.access.length === 0 ||
336
- typeof value.refresh !== "string" ||
337
- value.refresh.length === 0 ||
338
- typeof value.expires !== "number" ||
339
- !Number.isFinite(value.expires)
340
- ) {
341
- throw new Error("Cursor credential store received a malformed OAuth credential");
342
- }
343
- return clone(value);
344
- }
345
-
346
- function parseOAuthCredential(raw) {
347
- try {
348
- return assertOAuthCredential(JSON.parse(raw));
349
- } catch (error) {
350
- if (error?.message === "Cursor credential store received a malformed OAuth credential") throw error;
351
- throw new Error("Cursor credential store contains malformed OAuth JSON", { cause: error });
352
- }
353
- }
354
-
355
- /**
356
- * Adapt DSH's managed string credential service to Cursor's typed OAuth
357
- * credential. Write operations are serialized so an older refresh response
358
- * cannot overwrite a newer rotated token.
359
- */
360
- export class CursorCredentialStore {
361
- #chain = Promise.resolve();
362
-
363
- constructor(credentials, ref) {
364
- if (credentials === undefined || credentials === null) {
365
- throw new Error("Cursor OAuth requires the DSH credentials service");
366
- }
367
- this.credentials = credentials;
368
- this.ref = ref;
369
- }
370
-
371
- #enqueue(operation) {
372
- const current = this.#chain.catch(() => undefined).then(operation);
373
- const tail = current.catch(() => undefined);
374
- this.#chain = tail;
375
- return current;
376
- }
377
-
378
- async read() {
379
- const hit = await this.credentials.resolve(this.ref);
380
- if (hit?.value === undefined || hit.value === "") return undefined;
381
- return parseOAuthCredential(hit.value);
382
- }
383
-
384
- async write(credential) {
385
- const validated = assertOAuthCredential(credential);
386
- if (validated === undefined) throw new Error("Cursor credential store cannot write an empty credential");
387
- await this.credentials.set(this.ref, JSON.stringify(validated));
388
- return clone(validated);
389
- }
390
-
391
- modify(update) {
392
- return this.#enqueue(async () => {
393
- const current = await this.read();
394
- const next = await update(clone(current));
395
- if (next === undefined) return current;
396
- return this.write(next);
397
- });
398
- }
399
-
400
- async clear() {
401
- await this.#enqueue(async () => {
402
- await this.credentials.unset(this.ref);
403
- });
404
- }
405
- }
406
- //#endregion
407
-
408
- //#region token helpers
409
- /**
410
- * Extract a JWT expiry (ms epoch) with a safety margin.
411
- * Falls back to a default lifetime when the token cannot be parsed.
412
- */
413
- export function getTokenExpiry(token, now = Date.now) {
414
- try {
415
- const parts = token.split(".");
416
- if (parts.length !== 3 || !parts[1]) return now() + DEFAULT_TOKEN_LIFETIME_MS;
417
- const decoded = JSON.parse(
418
- Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
419
- );
420
- if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") {
421
- return decoded.exp * 1000;
422
- }
423
- } catch {}
424
- return now() + DEFAULT_TOKEN_LIFETIME_MS;
425
- }
426
-
427
- /**
428
- * Decode the `sub` claim of an access token and strip the identity-provider
429
- * prefix (e.g. `github|user_...` → `user_...`), which is what the dashboard
430
- * session cookie and the `/api/usage?user=` parameter expect.
431
- */
432
- export function getTokenSub(token) {
433
- try {
434
- const parts = token.split(".");
435
- if (parts.length !== 3 || !parts[1]) return undefined;
436
- const decoded = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
437
- if (typeof decoded?.sub !== "string" || decoded.sub.length === 0) return undefined;
438
- return decoded.sub.includes("|") ? decoded.sub.split("|").pop() : decoded.sub;
439
- } catch {
440
- return undefined;
441
- }
442
- }
443
- //#endregion
444
-
445
- //#region pkce
446
- async function generatePkce() {
447
- const verifierBytes = new Uint8Array(96);
448
- globalThis.crypto.getRandomValues(verifierBytes);
449
- const verifier = Buffer.from(verifierBytes).toString("base64url");
450
- const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
451
- const challenge = Buffer.from(digest).toString("base64url");
452
- return { verifier, challenge };
453
- }
454
-
455
- /** Build the browser login URL for the PKCE flow. */
456
- export function buildLoginUrl({ challenge, uuid }) {
457
- const params = new URLSearchParams({ challenge, uuid, mode: "login", redirectTarget: "cli" });
458
- return `${CURSOR_LOGIN_URL}?${params.toString()}`;
459
- }
460
- //#endregion
461
-
462
- //#region external url
463
- /** Validate the only external origin this plugin may launch. */
464
- export function assertCursorAuthUrl(value) {
465
- let url;
466
- try {
467
- url = new URL(value);
468
- } catch {
469
- throw new Error("Cursor auth URL is invalid");
470
- }
471
- if (url.protocol !== "https:") throw new Error("Cursor auth URL must use HTTPS");
472
- if (url.origin !== CURSOR_AUTH_ORIGIN || url.username !== "" || url.password !== "") {
473
- throw new Error("Cursor auth URL must use the cursor.com origin");
474
- }
475
- return url.href;
476
- }
477
-
478
- /** Return a shell-free native opener command for the current desktop. */
479
- export function commandForCursorAuthUrl(value, platform = process.platform) {
480
- const url = assertCursorAuthUrl(value);
481
- if (platform === "win32") return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url], shell: false };
482
- if (platform === "darwin") return { file: "open", args: [url], shell: false };
483
- if (platform === "linux") return { file: "xdg-open", args: [url], shell: false };
484
- throw new Error(`Cursor auth URL opener is unsupported on ${platform}`);
485
- }
486
-
487
- export function openCursorAuthUrl(value, options = {}) {
488
- const command = commandForCursorAuthUrl(value, options.platform);
489
- const spawnProcess = options.spawn ?? spawn;
490
- return new Promise((resolve, reject) => {
491
- const child = spawnProcess(command.file, command.args, {
492
- detached: true,
493
- stdio: "ignore",
494
- windowsHide: true,
495
- shell: command.shell,
496
- });
497
- child.once("error", reject);
498
- child.once("spawn", () => {
499
- child.unref();
500
- resolve();
501
- });
502
- });
503
- }
504
- //#endregion
505
-
506
- //#region auth service
507
- /**
508
- * Owns the Cursor OAuth lifecycle: PKCE login polling, refresh, and status.
509
- * Tokens live in the DSH credential store; the browser client only ever sees
510
- * `{ authenticated, provider, type, expiresAt }`.
511
- */
512
- export class CursorAuthService {
513
- constructor(store, options = {}) {
514
- this.store = store;
515
- this.fetch = options.fetch ?? nativeFetch;
516
- this.now = options.now ?? Date.now;
517
- this.logger = options.logger;
518
- }
519
-
520
- #log(level, message, ...args) {
521
- try {
522
- this.logger?.[level]?.(`cursor-subscription: ${message}`, ...args);
523
- } catch {}
524
- }
525
-
526
- async status({ signal } = {}) {
527
- signal?.throwIfAborted();
528
- const current = await this.store.read();
529
- if (current === undefined) return { authenticated: false, provider: PROVIDER };
530
- return {
531
- authenticated: true,
532
- provider: PROVIDER,
533
- type: "oauth",
534
- expiresAt: current.expires,
535
- };
536
- }
537
-
538
- /**
539
- * Resolve a usable access token, refreshing first when the stored token is
540
- * missing, expired, or about to expire.
541
- * @returns {Promise<string>} the bearer token.
542
- */
543
- async accessToken({ signal } = {}) {
544
- const credential = await this.credential({ signal });
545
- return credential.access;
546
- }
547
-
548
- /**
549
- * Resolve the stored credential, refreshing first when its token is
550
- * missing, expired, or about to expire.
551
- * @returns {Promise<{access: string, refresh: string, expires: number}>}
552
- */
553
- async credential({ signal } = {}) {
554
- signal?.throwIfAborted();
555
- const current = await this.store.read();
556
- if (current === undefined) {
557
- throw new LlmError("Cursor subscription is not signed in", "MISSING_CREDENTIAL");
558
- }
559
- if (current.expires - this.now() > REFRESH_AHEAD_MS) return current;
560
- const refreshed = await this.refresh(current, { signal });
561
- return refreshed;
562
- }
563
-
564
- async refresh(current, { signal } = {}) {
565
- const response = await this.fetch(`${CURSOR_BASE_URL}${CURSOR_REFRESH_PATH}`, {
566
- method: "POST",
567
- redirect: "error",
568
- headers: {
569
- authorization: `Bearer ${current.refresh}`,
570
- "content-type": "application/json",
571
- accept: "application/json",
572
- "user-agent": "dsh-cursor-subscription/0.1.0",
573
- },
574
- body: "{}",
575
- signal,
576
- });
577
- if (!response.ok) {
578
- this.#log("warn", "token refresh failed (HTTP %s)", response.status);
579
- if (response.status === 401 || response.status === 403) {
580
- throw new LlmError("Cursor sign-in needs to be renewed", "INVALID_CREDENTIAL");
581
- }
582
- throw new LlmError(`Cursor token refresh failed (HTTP ${response.status})`, "AUTH_FAILED");
583
- }
584
- let data;
585
- try {
586
- data = await response.json();
587
- } catch (error) {
588
- throw new LlmError("Cursor returned an unreadable token response", "AUTH_FAILED", { cause: error });
589
- }
590
- if (typeof data?.accessToken !== "string" || data.accessToken.length === 0) {
591
- throw new LlmError("Cursor sign-in needs to be renewed", "INVALID_CREDENTIAL");
592
- }
593
- const next = {
594
- type: "oauth",
595
- access: data.accessToken,
596
- refresh: typeof data.refreshToken === "string" && data.refreshToken.length > 0 ? data.refreshToken : current.refresh,
597
- expires: getTokenExpiry(data.accessToken, this.now),
598
- };
599
- const stored = await this.store.modify((latest) => {
600
- // Only rotate when the stored credential is still the one we refreshed.
601
- if (latest === undefined || latest.refresh !== current.refresh) return latest;
602
- return next;
603
- });
604
- return stored ?? next;
605
- }
606
-
607
- /** Start a login and resolve once the browser flow completes. */
608
- async login({ interaction, signal }) {
609
- signal?.throwIfAborted();
610
- const { verifier, challenge } = await generatePkce();
611
- const uuid = randomUUID();
612
- const loginUrl = buildLoginUrl({ challenge, uuid });
613
-
614
- interaction.notify?.({
615
- type: "auth_url",
616
- url: assertCursorAuthUrl(loginUrl),
617
- instructions: "在浏览器中登录 Cursor 并授权后,此页面会自动完成登录。",
618
- });
619
- interaction.prompt?.({ type: "text", message: "等待浏览器登录完成…" }).catch(() => {});
620
- interaction.signal?.throwIfAborted();
621
-
622
- let delay = 1000;
623
- for (let attempt = 0; attempt < 150; attempt++) {
624
- await sleep(delay);
625
- signal?.throwIfAborted();
626
- interaction.signal?.throwIfAborted();
627
- let response;
628
- try {
629
- response = await this.fetch(`${CURSOR_BASE_URL}${CURSOR_POLL_PATH}?uuid=${encodeURIComponent(uuid)}&verifier=${encodeURIComponent(verifier)}`, {
630
- redirect: "error",
631
- headers: { accept: "application/json", "user-agent": "dsh-cursor-subscription/0.1.0" },
632
- signal,
633
- });
634
- } catch (error) {
635
- this.#log("warn", "login poll request failed on attempt %d: %s", attempt, error?.cause?.code ?? error?.message);
636
- throw new LlmError("Cursor login poll request failed", "AUTH_FAILED", { cause: error });
637
- }
638
- if (response.status === 404) {
639
- delay = Math.min(delay * 1.2, 10000);
640
- continue;
641
- }
642
- if (!response.ok) {
643
- this.#log("warn", "login poll returned HTTP %s on attempt %d", response.status, attempt);
644
- throw new LlmError(`Cursor login poll failed (HTTP ${response.status})`, "AUTH_FAILED");
645
- }
646
- const text = await Promise.resolve(response.text()).catch(() => "");
647
- this.#log("info", "login poll succeeded after %d attempts", attempt);
648
- let data;
649
- try {
650
- data = JSON.parse(text);
651
- } catch (error) {
652
- this.#log("warn", "login poll returned unreadable JSON: %s", text.slice(0, 200));
653
- throw new LlmError("Cursor login returned an unreadable response", "AUTH_FAILED", { cause: error });
654
- }
655
- if (typeof data?.accessToken !== "string" || data.accessToken.length === 0) {
656
- this.#log("warn", "login poll response had no accessToken");
657
- throw new LlmError("Cursor login returned no access token", "AUTH_FAILED");
658
- }
659
- const credential = {
660
- type: "oauth",
661
- access: data.accessToken,
662
- refresh: typeof data.refreshToken === "string" && data.refreshToken.length > 0 ? data.refreshToken : "",
663
- expires: getTokenExpiry(data.accessToken, this.now),
664
- };
665
- try {
666
- await this.store.modify(() => credential);
667
- } catch (error) {
668
- this.#log("error", "failed to store Cursor credential: %s", error?.message);
669
- throw new LlmError("Cursor login could not store the credential", "AUTH_FAILED", { cause: error });
670
- }
671
- return;
672
- }
673
- this.#log("warn", "login timed out after 150 poll attempts");
674
- throw new LlmError("Cursor login timed out", "AUTH_FAILED");
675
- }
676
-
677
- async logout({ signal } = {}) {
678
- signal?.throwIfAborted();
679
- await this.store.clear();
680
- return this.status({ signal });
681
- }
682
- }
683
-
684
- function sleep(ms) {
685
- return new Promise((resolve) => setTimeout(resolve, ms));
686
- }
687
- //#endregion
688
-
689
- //#region login coordinator
690
- const TERMINAL_PHASES = new Set(["authenticated", "failed", "cancelled"]);
691
- const publicClone = (value) => structuredClone(value);
692
- const asObject = (value) => (value !== null && typeof value === "object" ? value : {});
693
- const ok = (value) => ({ ok: true, value });
694
- const badRequest = (message) => ({ ok: false, error: { code: "bad-request", message, details: { issues: [] } } });
695
-
696
- const deferred = () => {
697
- let resolve;
698
- let reject;
699
- return {
700
- promise: new Promise((onResolve, onReject) => {
701
- resolve = onResolve;
702
- reject = onReject;
703
- }),
704
- resolve,
705
- reject,
706
- };
707
- };
708
-
709
- /** Own one host-side login without exposing tokens to the browser client. */
710
- export class CursorLoginCoordinator {
711
- #sessions = new Map();
712
- #activeId;
713
-
714
- constructor(auth, options = {}) {
715
- this.auth = auth;
716
- this.createId = options.createId ?? (() => randomUUID());
717
- this.logger = options.logger;
718
- }
719
-
720
- #log(level, message, ...args) {
721
- try {
722
- this.logger?.[level]?.(`cursor-subscription: ${message}`, ...args);
723
- } catch {}
724
- }
725
-
726
- async accountStatus(options) {
727
- return publicClone(await this.auth.status(options));
728
- }
729
-
730
- async start() {
731
- const active = this.#activeId === undefined ? undefined : this.#sessions.get(this.#activeId);
732
- if (active !== undefined && !TERMINAL_PHASES.has(active.view.phase)) {
733
- throw new Error("a Cursor login is already active");
734
- }
735
- if (active !== undefined) this.#sessions.delete(active.view.id);
736
- const id = this.createId();
737
- const ready = deferred();
738
- const controller = new AbortController();
739
- const session = {
740
- controller,
741
- ready,
742
- view: { id, provider: PROVIDER, method: "browser", phase: "starting", authenticated: false },
743
- };
744
- this.#sessions.set(id, session);
745
- this.#activeId = id;
746
- const publishReady = () => ready.resolve(this.read(id));
747
- const interaction = {
748
- signal: controller.signal,
749
- prompt: async (prompt) => {
750
- controller.signal.throwIfAborted();
751
- const answer = deferred();
752
- session.prompt = answer;
753
- session.view = { ...session.view, phase: "waiting_input", prompt: publicPrompt(prompt) };
754
- const abortPrompt = () => answer.reject(controller.signal.reason ?? new Error("login cancelled"));
755
- controller.signal.addEventListener("abort", abortPrompt, { once: true });
756
- prompt.signal?.addEventListener("abort", abortPrompt, { once: true });
757
- publishReady();
758
- try {
759
- return await answer.promise;
760
- } finally {
761
- controller.signal.removeEventListener("abort", abortPrompt);
762
- prompt.signal?.removeEventListener("abort", abortPrompt);
763
- if (session.prompt === answer) session.prompt = undefined;
764
- }
765
- },
766
- notify: (event) => {
767
- if (controller.signal.aborted) return;
768
- if (event.type === "auth_url") {
769
- session.view = {
770
- ...session.view,
771
- phase: "waiting_browser",
772
- authUrl: assertCursorAuthUrl(event.url),
773
- ...typeof event.instructions === "string" ? { instructions: event.instructions } : {},
774
- };
775
- } else {
776
- session.view = { ...session.view, message: String(event.message ?? "") };
777
- }
778
- publishReady();
779
- },
780
- };
781
- session.run = Promise.resolve()
782
- .then(() => this.auth.login({ interaction, signal: controller.signal }))
783
- .then(async () => {
784
- if (controller.signal.aborted) return;
785
- const status = await this.auth.status();
786
- session.view = {
787
- id,
788
- provider: PROVIDER,
789
- method: "browser",
790
- phase: "authenticated",
791
- authenticated: status.authenticated === true,
792
- ...typeof status.expiresAt === "number" ? { expiresAt: status.expiresAt } : {},
793
- };
794
- })
795
- .catch((error) => {
796
- if (controller.signal.aborted) {
797
- session.view = { id, provider: PROVIDER, method: "browser", phase: "cancelled", authenticated: false };
798
- return;
799
- }
800
- const message = error instanceof Error ? error.message : String(error);
801
- this.#log("warn", "login failed: %s", message);
802
- session.view = {
803
- id,
804
- provider: PROVIDER,
805
- method: "browser",
806
- phase: "failed",
807
- authenticated: false,
808
- error: "Cursor login failed",
809
- ...(message ? { detail: message.slice(0, 500) } : {}),
810
- };
811
- session.hostError = error;
812
- })
813
- .finally(publishReady);
814
- return ready.promise;
815
- }
816
-
817
- read(id) {
818
- const session = this.#sessions.get(id);
819
- if (session === undefined) throw new Error("unknown Cursor login");
820
- return publicClone(session.view);
821
- }
822
-
823
- async cancel(id) {
824
- const session = this.#sessions.get(id);
825
- if (session === undefined) throw new Error("unknown Cursor login");
826
- if (!TERMINAL_PHASES.has(session.view.phase)) {
827
- session.view = { id, provider: PROVIDER, method: "browser", phase: "cancelled", authenticated: false };
828
- session.controller.abort(new Error("Cursor login cancelled"));
829
- }
830
- await Promise.resolve(session.run).catch(() => undefined);
831
- return this.read(id);
832
- }
833
-
834
- async logout(options) {
835
- if (this.#activeId !== undefined) {
836
- const active = this.#sessions.get(this.#activeId);
837
- if (active !== undefined && !TERMINAL_PHASES.has(active.view.phase)) await this.cancel(active.view.id);
838
- }
839
- await this.auth.logout(options);
840
- return this.accountStatus(options);
841
- }
842
- }
843
-
844
- const publicPrompt = (prompt) => ({
845
- type: prompt.type,
846
- message: String(prompt.message ?? ""),
847
- ...typeof prompt.placeholder === "string" ? { placeholder: prompt.placeholder } : {},
848
- });
849
-
850
- /** Map the loopback-only DSH Connection channel onto the coordinator. */
851
- export function createCursorRpcHandler(coordinator, options = {}) {
852
- const openExternal = options.openExternal;
853
- const usageReader = options.usageReader;
854
- const modelsProvider = options.modelsProvider;
855
- const settingsController = options.settings;
856
- const version = options.version ?? VERSION;
857
- const publicError = (code, message) => ({ ok: false, error: { code, message, details: { issues: [] } } });
858
- return async (endpoint, payload, signal) => {
859
- try {
860
- signal.throwIfAborted();
861
- const input = asObject(payload);
862
- // The panel labels itself with the build that is actually serving it, so the
863
- // version never has to be duplicated into the browser bundle.
864
- if (endpoint === "version") return ok({ version });
865
- if (endpoint === "settings") return ok(settingsController.read());
866
- if (endpoint === "settings/update") {
867
- if (!Number.isSafeInteger(input.revision) || input.revision < 0) throw new Error("invalid Cursor settings revision");
868
- const patch = {};
869
- for (const key of ["maxToolRounds", "retryCount", "retryIntervalMs", "retryHttpStatusCodes", "replayHistoryEachStep"]) {
870
- if (Object.hasOwn(input, key)) patch[key] = input[key];
871
- }
872
- return ok(await settingsController.update(patch, input.revision));
873
- }
874
- if (endpoint === "status") return ok(await coordinator.accountStatus({ signal }));
875
- if (endpoint === "usage") {
876
- try {
877
- return ok(await usageReader.read({ force: input.force === true, signal }));
878
- } catch (error) {
879
- const known = new Set(["Cursor subscription is not signed in", "Cursor sign-in needs to be renewed"]);
880
- if (error instanceof Error && known.has(error.message)) return publicError("internal", error.message);
881
- const raw = error instanceof Error ? error.message : "";
882
- const http = raw.match(/^HTTP (\d{3})\b/);
883
- const message = http ? `Could not read Cursor usage (HTTP ${http[1]})` : `Could not read Cursor usage: ${raw}`;
884
- return publicError("internal", message);
885
- }
886
- }
887
- if (endpoint === "models") {
888
- try {
889
- return ok({
890
- models: await modelsProvider.listModelsForRpc({ force: input.force === true, signal }),
891
- fetchedAt: Date.now(),
892
- });
893
- } catch (error) {
894
- return publicError("internal", error instanceof Error ? error.message : "Could not list Cursor models");
895
- }
896
- }
897
- if (endpoint === "login/start") {
898
- const started = await coordinator.start();
899
- if (input.openExternal !== true) return ok(started);
900
- const url = started.authUrl;
901
- if (typeof url !== "string" || openExternal === undefined) return ok({ ...started, externalOpened: false });
902
- try {
903
- await openExternal(url);
904
- return ok({ ...started, externalOpened: true });
905
- } catch {
906
- return ok({ ...started, externalOpened: false });
907
- }
908
- }
909
- if (endpoint === "login/status") return ok(coordinator.read(input.id));
910
- if (endpoint === "login/cancel") return ok(await coordinator.cancel(input.id));
911
- if (endpoint === "logout") {
912
- const result = await coordinator.logout({ signal });
913
- usageReader?.clear();
914
- return ok(result);
915
- }
916
- return badRequest(`unknown Cursor auth endpoint: ${endpoint}`);
917
- } catch (error) {
918
- if (signal.aborted) throw error;
919
- const message = error instanceof Error && /^(unknown|unsupported|a Cursor|Cursor login|Cursor auth URL)/.test(error.message)
920
- ? error.message
921
- : "Cursor request failed";
922
- return badRequest(message);
923
- }
924
- };
925
- }
926
- //#endregion
927
-
928
- //#region protobuf message builders (agent.v1 subset)
929
- const textEncoder = new TextEncoder();
930
-
931
- function bytesOf(value) {
932
- return value instanceof Uint8Array ? value : new Uint8Array(value);
933
- }
934
-
935
- function encodeUserMessage({ text, messageId }) {
936
- const writer = new Writer();
937
- if (typeof text === "string" && text.length > 0) writer.string(1, text); // text
938
- if (typeof messageId === "string" && messageId.length > 0) writer.string(2, messageId); // message_id
939
- return writer.finish();
940
- }
941
-
942
- function encodeAssistantMessage(text) {
943
- return new Writer().string(1, text).finish(); // text
944
- }
945
-
946
- /** ConversationStep { assistant_message = 1 } */
947
- function encodeAssistantStep(text) {
948
- const inner = encodeAssistantMessage(text);
949
- return new Writer().message(1, inner).finish();
950
- }
951
-
952
- /** AgentConversationTurnStructure { user_message = 1, steps = 2 } */
953
- function encodeAgentTurn(userBytes, stepBytes) {
954
- const writer = new Writer();
955
- if (userBytes.length > 0) writer.bytes(1, userBytes);
956
- for (const step of stepBytes) writer.bytes(2, step);
957
- return writer.finish();
958
- }
959
-
960
- /** ConversationTurnStructure { agent_conversation_turn = 1 } */
961
- function encodeTurnStructure(turnBytes) {
962
- return new Writer().message(1, turnBytes).finish();
963
- }
964
-
965
- /** ModelDetails { model_id = 1, display_model_id = 3, display_name = 4 } */
966
- function encodeModelDetails(modelId) {
967
- const writer = new Writer();
968
- writer.string(1, modelId);
969
- writer.string(3, modelId);
970
- writer.string(4, modelId);
971
- return writer.finish();
972
- }
973
-
974
- /**
975
- * ConversationHistoryImageContent { data = 1, mime_type = 2 }
976
- * wrapped as ConversationHistoryMessage.user.content[].image
977
- */
978
- function encodeConversationHistoryImage({ data, mimeType }) {
979
- const image = new Writer().string(1, data);
980
- if (mimeType) image.string(2, mimeType);
981
- const content = new Writer().message(2, image.finish()).finish(); // UserContent.image
982
- const user = new Writer().message(1, content).finish(); // UserMessage.content
983
- return new Writer().message(1, user).finish(); // HistoryMessage.user
984
- }
985
-
986
- /** ConversationHistory { messages = 1 } with one history message per image. */
987
- function encodeConversationHistory(images) {
988
- const writer = new Writer();
989
- for (const image of images) writer.message(1, encodeConversationHistoryImage(image));
990
- return writer.finish();
991
- }
992
-
993
- /** ConversationAction { user_message_action = 1 } → UserMessageAction { user_message = 1, conversation_history = 7 } */
994
- function encodeUserMessageAction(userBytes, images = []) {
995
- const inner = new Writer().message(1, userBytes); // UserMessageAction.user_message
996
- if (images.length > 0) inner.message(7, encodeConversationHistory(images));
997
- return new Writer().message(1, inner.finish()).finish(); // ConversationAction
998
- }
999
-
1000
- /** ConversationStateStructure — the durable conversation payload. */
1001
- function encodeConversationState({ rootPromptBlobIds, turns }) {
1002
- const writer = new Writer();
1003
- for (const id of rootPromptBlobIds) writer.bytes(1, id); // root_prompt_messages_json
1004
- for (const turn of turns) writer.bytes(8, turn); // turns
1005
- return writer.finish();
1006
- }
1007
-
1008
- /** AgentRunRequest { conversation_state=1, action=2, model_details=3, conversation_id=5 } */
1009
- function encodeRunRequest({ conversationState, action, modelDetails, conversationId }) {
1010
- const writer = new Writer();
1011
- writer.message(1, conversationState);
1012
- writer.message(2, action);
1013
- writer.message(3, modelDetails);
1014
- if (conversationId) writer.string(5, conversationId);
1015
- return writer.finish();
1016
- }
1017
-
1018
- /** AgentClientMessage { run_request = 1 } */
1019
- function encodeRunMessage(runRequestBytes) {
1020
- return new Writer().message(1, runRequestBytes).finish();
1021
- }
1022
-
1023
- /** AgentClientMessage { client_heartbeat = 7 } */
1024
- function encodeHeartbeat() {
1025
- return new Writer().message(7, new Uint8Array(0)).finish();
1026
- }
1027
-
1028
- /** KvClientMessage { id=1, get_blob_result=2 { blob_data=1 } } */
1029
- export function encodeGetBlobResult(id, blobData) {
1030
- const inner = blobData === undefined ? new Uint8Array(0) : blobData;
1031
- const result = new Writer().bytes(1, inner).finish(); // GetBlobResult
1032
- const writer = new Writer();
1033
- writer.varint(1, id);
1034
- writer.message(2, result);
1035
- return writer.finish();
1036
- }
1037
-
1038
- /** AgentClientMessage { kv_client_message = 3 } */
1039
- export function encodeKvClientMessage(kvBytes) {
1040
- return new Writer().message(3, kvBytes).finish();
1041
- }
1042
-
1043
- /** KvClientMessage { id=1, set_blob_result=3 } — acknowledge a server blob write. */
1044
- export function encodeSetBlobResult(id) {
1045
- const writer = new Writer();
1046
- writer.varint(1, id);
1047
- writer.message(3, new Uint8Array(0)); // set_blob_result (empty success)
1048
- return writer.finish();
1049
- }
1050
-
1051
- /** McpResult.success with one text content item. */
1052
- export function encodeMcpResult({ content, isError = false }) {
1053
- return encodeMcpResultSuccess(String(content ?? ""), isError);
1054
- }
1055
-
1056
- /** ExecClientMessage { id=1, exec_id=15, message=... } */
1057
- export function encodeExecClientMessage(id, execId, messageField, messageBytes) {
1058
- const writer = new Writer();
1059
- writer.varint(1, id);
1060
- if (typeof execId === "string" && execId.length > 0) writer.string(15, execId);
1061
- if (messageBytes !== undefined) writer.message(messageField, messageBytes);
1062
- return writer.finish();
1063
- }
1064
-
1065
- /** AgentClientMessage { exec_client_message = 2 } */
1066
- export function encodeExecClientMessageEnvelope(execBytes) {
1067
- return new Writer().message(2, execBytes).finish();
1068
- }
1069
-
1070
- /** RequestContextResult { success=1 { request_context=1 } } with tools. */
1071
- export function encodeRequestContextResult(tools) {
1072
- const context = new Writer();
1073
- for (const tool of tools) context.message(7, tool); // RequestContext.tools
1074
- const success = new Writer().message(1, context.finish()).finish(); // RequestContextSuccess
1075
- return new Writer().message(1, success).finish(); // RequestContextResult
1076
- }
1077
-
1078
- /** McpToolDefinition { name=1, description=2, input_schema=3, provider_identifier=4, tool_name=5 } */
1079
- export function encodeMcpToolDefinition({ name, description, inputSchema, providerIdentifier, toolName }) {
1080
- const writer = new Writer();
1081
- writer.string(1, name);
1082
- writer.string(2, description ?? "");
1083
- writer.bytes(3, bytesOf(inputSchema));
1084
- writer.string(4, providerIdentifier ?? "dsh-cursor-subscription");
1085
- writer.string(5, toolName ?? name);
1086
- return writer.finish();
1087
- }
1088
-
1089
- /** McpTextContent { text = 1 } */
1090
- function encodeMcpTextContent(text) {
1091
- return new Writer().string(1, text).finish();
1092
- }
1093
-
1094
- /** McpToolResultContentItem { text = 1 } */
1095
- function encodeMcpToolResultContentItem(text) {
1096
- return new Writer().message(1, encodeMcpTextContent(text)).finish();
1097
- }
1098
-
1099
- /** McpSuccess { content=1, is_error=2 } */
1100
- function encodeMcpSuccess(text, isError) {
1101
- const writer = new Writer();
1102
- writer.message(1, encodeMcpToolResultContentItem(text));
1103
- writer.varint(2, isError ? 1 : 0);
1104
- return writer.finish();
1105
- }
1106
-
1107
- /** McpResult { success=1 | error=2 } */
1108
- function encodeMcpResultSuccess(text, isError) {
1109
- return new Writer().message(1, encodeMcpSuccess(text, isError)).finish();
1110
- }
1111
-
1112
- function encodeMcpError(error) {
1113
- return new Writer().message(2, new Writer().string(1, error).finish()).finish();
1114
- }
1115
-
1116
- /** ReadResult { rejected=3 { path=1, reason=2 } } */
1117
- function encodeReadRejected(path, reason) {
1118
- const rejected = new Writer().string(1, path ?? "").string(2, reason).finish();
1119
- return new Writer().message(3, rejected).finish();
1120
- }
1121
-
1122
- /** LsResult { rejected=3 { path=1, reason=2 } } */
1123
- function encodeLsRejected(path, reason) {
1124
- const rejected = new Writer().string(1, path ?? "").string(2, reason).finish();
1125
- return new Writer().message(3, rejected).finish();
1126
- }
1127
-
1128
- /** GrepResult { error=2 { error=1 } } */
1129
- function encodeGrepError(error) {
1130
- return new Writer().message(2, new Writer().string(1, error).finish()).finish();
1131
- }
1132
-
1133
- /** WriteResult { rejected = 6 } (oneof: success=1, permission_denied=3, no_space=4, error=5, rejected=6) */
1134
- function encodeWriteRejected(path, reason) {
1135
- const rejected = new Writer().string(1, path ?? "").string(2, reason).finish();
1136
- return new Writer().message(6, rejected).finish();
1137
- }
1138
-
1139
- /** DeleteResult { rejected = 6 } (oneof: success=1, file_not_found=2, not_file=3, permission_denied=4, file_busy=5, rejected=6) */
1140
- function encodeDeleteRejected(path, reason) {
1141
- const rejected = new Writer().string(1, path ?? "").string(2, reason).finish();
1142
- return new Writer().message(6, rejected).finish();
1143
- }
1144
-
1145
- /** ShellRejected { command=1, working_directory=2, reason=3, is_readonly=4 } */
1146
- function encodeShellRejected(command, workingDirectory, reason) {
1147
- const writer = new Writer();
1148
- writer.string(1, command ?? "");
1149
- writer.string(2, workingDirectory ?? "");
1150
- writer.string(3, reason);
1151
- writer.varint(4, 0);
1152
- return writer.finish();
1153
- }
1154
-
1155
- /** ShellResult { rejected = 4 } (oneof: success=1, failure=2, timeout=3, rejected=4) */
1156
- function encodeShellRejectedResult(command, workingDirectory, reason) {
1157
- return new Writer().message(4, encodeShellRejected(command, workingDirectory, reason)).finish();
1158
- }
1159
-
1160
- /**
1161
- * ShellStream { rejected = 5 } — the reply type for `shellStreamArgs` execs
1162
- * (streaming shell), carried by ExecClientMessage.shell_stream (field 14).
1163
- */
1164
- function encodeShellStreamRejected(command, workingDirectory, reason) {
1165
- return new Writer().message(5, encodeShellRejected(command, workingDirectory, reason)).finish();
1166
- }
1167
-
1168
- /** BackgroundShellSpawnResult { rejected = 3 } (oneof: success=1, error=2, rejected=3, permission_denied=4) */
1169
- function encodeBackgroundShellRejectedResult(command, workingDirectory, reason) {
1170
- return new Writer().message(3, encodeShellRejected(command, workingDirectory, reason)).finish();
1171
- }
1172
-
1173
- /** FetchResult { error=2 { url=1, error=2 } } */
1174
- function encodeFetchError(url, error) {
1175
- const inner = new Writer().string(1, url ?? "").string(2, error).finish();
1176
- return new Writer().message(2, inner).finish();
1177
- }
1178
-
1179
- /** WriteShellStdinResult { error=2 { error=1 } } */
1180
- function encodeWriteShellStdinError(error) {
1181
- return new Writer().message(2, new Writer().string(1, error).finish()).finish();
1182
- }
1183
-
1184
- /** DiagnosticsResult { success = 1 } — empty success keeps the exec well-formed. */
1185
- function encodeDiagnosticsResult() {
1186
- return new Writer().message(1, new Uint8Array(0)).finish();
1187
- }
1188
- //#endregion
1189
-
1190
- //#region protobuf message parsers (agent.v1 subset)
1191
- const decoder = new TextDecoder();
1192
-
1193
- function readTag(reader) {
1194
- if (reader.done) return undefined;
1195
- const { field, wireType } = reader.tag();
1196
- return { field, wireType };
1197
- }
1198
-
1199
- /** AgentServerMessage { interaction_update=1, exec_server_message=2, kv_server_message=4, ... } */
1200
- export function decodeAgentServerMessage(bytes) {
1201
- const reader = new Reader(bytes);
1202
- while (!reader.done) {
1203
- const { field, wireType } = reader.tag();
1204
- if (wireType !== 2) {
1205
- reader.skip(wireType);
1206
- continue;
1207
- }
1208
- const payload = reader.bytes();
1209
- if (field === 1) return { case: "interactionUpdate", value: decodeInteractionUpdate(payload) };
1210
- if (field === 2) return { case: "execServerMessage", value: decodeExecServerMessage(payload) };
1211
- if (field === 3) return { case: "conversationCheckpointUpdate", value: payload };
1212
- if (field === 4) return { case: "kvServerMessage", value: decodeKvServerMessage(payload) };
1213
- // 5 = exec_server_control, 7 = interaction_query (ignored)
1214
- }
1215
- return { case: "unknown", value: undefined };
1216
- }
1217
-
1218
- /** Field numbers inside one protobuf message; used to describe frames we do not decode. */
1219
- function messageFields(bytes) {
1220
- const reader = new Reader(bytes);
1221
- const fields = [];
1222
- while (!reader.done) {
1223
- const { field, wireType } = reader.tag();
1224
- fields.push(field);
1225
- reader.skip(wireType);
1226
- }
1227
- return fields.join(",");
1228
- }
1229
-
1230
- /** Field numbers of a nested message (`wanted` inside `bytes`). */
1231
- function nestedMessageFields(bytes, wanted) {
1232
- const reader = new Reader(bytes);
1233
- while (!reader.done) {
1234
- const { field, wireType } = reader.tag();
1235
- if (wireType !== 2) {
1236
- reader.skip(wireType);
1237
- continue;
1238
- }
1239
- const payload = reader.bytes();
1240
- if (field === wanted) return messageFields(payload);
1241
- }
1242
- return "";
1243
- }
1244
-
1245
- /**
1246
- * One-line description of a server frame for the stall trace. Undecoded
1247
- * frames report their protobuf field numbers so a server message the adapter
1248
- * ignores is still visible when a run goes silent.
1249
- */
1250
- export function describeServerFrame(frame) {
1251
- if ((frame.flags & CONNECT_END_STREAM_FLAG) !== 0) {
1252
- return `endstream(${Buffer.from(frame.payload).toString("utf8").replace(/\s+/g, " ").slice(0, 60)})`;
1253
- }
1254
- try {
1255
- const message = decodeAgentServerMessage(frame.payload);
1256
- if (message.case === "interactionUpdate") {
1257
- const update = message.value;
1258
- if (update.type === "textDelta") return `text(${update.text.length})`;
1259
- if (update.type === "thinkingDelta") return `thinking(${update.text.length})`;
1260
- if (update.type === "tokenDelta") return `tokens(${update.tokens})`;
1261
- if (update.type === "partialToolCall") return "partialToolCall";
1262
- if (update.type === "unknown") return `interaction#${nestedMessageFields(frame.payload, 1)}`;
1263
- return update.type;
1264
- }
1265
- if (message.case === "conversationCheckpointUpdate") return `checkpoint(${message.value.length}B)`;
1266
- if (message.case === "kvServerMessage") return `kv:${message.value.case}`;
1267
- if (message.case === "execServerMessage") {
1268
- const exec = message.value;
1269
- return exec.case === "unknown"
1270
- ? `exec:unknown#${exec.field ?? "?"}[${(exec.seen ?? []).join(",")}]`
1271
- : `exec:${exec.case}`;
1272
- }
1273
- return `message#${messageFields(frame.payload)}`;
1274
- } catch (error) {
1275
- return `undecodable(${(error instanceof Error ? error.message : String(error)).slice(0, 40)})`;
1276
- }
1277
- }
1278
-
1279
- /**
1280
- * Prompt tokens Cursor reports for the conversation it is holding.
1281
- *
1282
- * `ConversationStateStructure.token_details.used_tokens` rides in every
1283
- * `conversation_checkpoint_update`, so the run that resumes that checkpoint can
1284
- * report the real prompt size. Without it the adapter reported a placeholder
1285
- * zero, and the context gauge showed nothing however large the model's
1286
- * conversation had grown.
1287
- *
1288
- * @param checkpoint Raw checkpoint bytes from the last update, if any.
1289
- * @returns Used tokens, or undefined when the field is absent or unreadable.
1290
- */
1291
- export function decodeCheckpointUsedTokens(checkpoint) {
1292
- if (checkpoint === undefined) return undefined;
1293
- try {
1294
- const reader = new Reader(checkpoint);
1295
- while (!reader.done) {
1296
- const { field, wireType } = reader.tag();
1297
- if (field === 5 && wireType === 2) {
1298
- const details = new Reader(reader.bytes());
1299
- while (!details.done) {
1300
- const tag = details.tag();
1301
- if (tag.field === 1 && tag.wireType === 0) return details.varint();
1302
- details.skip(tag.wireType);
1303
- }
1304
- return undefined;
1305
- }
1306
- reader.skip(wireType);
1307
- }
1308
- } catch {
1309
- // A checkpoint this build cannot parse still resumes the conversation;
1310
- // the usage figure stays unknown rather than failing the step.
1311
- }
1312
- return undefined;
1313
- }
1314
-
1315
- /** InteractionUpdate { text_delta=1, thinking_delta=4, token_delta=8, turn_ended=14, ... } */
1316
- export function decodeInteractionUpdate(bytes) {
1317
- const reader = new Reader(bytes);
1318
- while (!reader.done) {
1319
- const { field, wireType } = reader.tag();
1320
- if (wireType !== 2) {
1321
- reader.skip(wireType);
1322
- continue;
1323
- }
1324
- const payload = reader.bytes();
1325
- if (field === 1) return { type: "textDelta", text: decodeTextDelta(payload) }; // TextDeltaUpdate.text=1
1326
- if (field === 4) return { type: "thinkingDelta", text: decodeTextDelta(payload) }; // ThinkingDeltaUpdate.text=1
1327
- if (field === 5) return { type: "thinkingCompleted", durationMs: decodeThinkingDuration(payload) }; // ThinkingCompletedUpdate.thinking_duration_ms=1
1328
- if (field === 8) return { type: "tokenDelta", tokens: decodeTokenDelta(payload) }; // TokenDeltaUpdate.tokens=1
1329
- if (field === 14) return { type: "turnEnded" };
1330
- if (field === 2) return { type: "toolCallStarted" };
1331
- if (field === 3) return { type: "toolCallCompleted" };
1332
- if (field === 7) return { type: "partialToolCall", ...decodePartialToolCall(payload) };
1333
- if (field === 15) return { type: "toolCallDelta" };
1334
- if (field === 13) return { type: "heartbeat" };
1335
- // others (summary*, step_*, user_message_appended, shell_output_delta) ignored
1336
- }
1337
- return { type: "unknown" };
1338
- }
1339
-
1340
- function decodeThinkingDuration(bytes) {
1341
- const reader = new Reader(bytes);
1342
- while (!reader.done) {
1343
- const { field, wireType } = reader.tag();
1344
- if (field === 1 && wireType === 0) return reader.varint();
1345
- reader.skip(wireType);
1346
- }
1347
- return 0;
1348
- }
1349
-
1350
- function decodeTextDelta(bytes) {
1351
- const reader = new Reader(bytes);
1352
- while (!reader.done) {
1353
- const { field, wireType } = reader.tag();
1354
- if (field === 1 && wireType === 2) return reader.string();
1355
- reader.skip(wireType);
1356
- }
1357
- return "";
1358
- }
1359
-
1360
- function decodeTokenDelta(bytes) {
1361
- const reader = new Reader(bytes);
1362
- while (!reader.done) {
1363
- const { field, wireType } = reader.tag();
1364
- if (field === 1 && wireType === 0) return reader.varint();
1365
- reader.skip(wireType);
1366
- }
1367
- return 0;
1368
- }
1369
-
1370
- /** PartialToolCallUpdate { call_id=1, args_text_delta=3 } */
1371
- function decodePartialToolCall(bytes) {
1372
- const reader = new Reader(bytes);
1373
- let callId = "";
1374
- let argsTextDelta = "";
1375
- while (!reader.done) {
1376
- const { field, wireType } = reader.tag();
1377
- if (field === 1 && wireType === 2) callId = reader.string();
1378
- else if (field === 3 && wireType === 2) argsTextDelta = reader.string();
1379
- else reader.skip(wireType);
1380
- }
1381
- return { callId, argsTextDelta };
1382
- }
1383
-
1384
- /** KvServerMessage { id=1, get_blob_args=2 { blob_id=1 } | set_blob_args=3 { blob_id=1, blob_data=2 } } */
1385
- export function decodeKvServerMessage(bytes) {
1386
- const reader = new Reader(bytes);
1387
- let id = 0;
1388
- let blobId;
1389
- while (!reader.done) {
1390
- const { field, wireType } = reader.tag();
1391
- if (field === 1 && wireType === 0) id = reader.varint();
1392
- else if (field === 2 && wireType === 2) blobId = decodeBlobId(reader.bytes());
1393
- else if (field === 3 && wireType === 2) {
1394
- return { id, case: "setBlobArgs", ...decodeSetBlobArgs(reader.bytes()) };
1395
- } else reader.skip(wireType);
1396
- }
1397
- return blobId === undefined ? { id, case: "unknown" } : { id, case: "getBlobArgs", blobId };
1398
- }
1399
-
1400
- /** SetBlobArgs { blob_id=1, blob_data=2 } */
1401
- function decodeSetBlobArgs(bytes) {
1402
- const reader = new Reader(bytes);
1403
- let blobId;
1404
- let blobData;
1405
- while (!reader.done) {
1406
- const { field, wireType } = reader.tag();
1407
- if (field === 1 && wireType === 2) blobId = reader.bytes();
1408
- else if (field === 2 && wireType === 2) blobData = reader.bytes();
1409
- else reader.skip(wireType);
1410
- }
1411
- return { blobId, blobData };
1412
- }
1413
-
1414
- function decodeBlobId(bytes) {
1415
- const reader = new Reader(bytes);
1416
- while (!reader.done) {
1417
- const { field, wireType } = reader.tag();
1418
- if (field === 1 && wireType === 2) return reader.bytes();
1419
- reader.skip(wireType);
1420
- }
1421
- return undefined;
1422
- }
1423
-
1424
- /**
1425
- * Field number of `ExecServerMessage.span_context`. It is a plain (non-oneof)
1426
- * field, so a message carrying only ids, a span context, and an exec variant
1427
- * this build does not know must not mistake it for the exec itself.
1428
- */
1429
- const EXEC_SPAN_CONTEXT_FIELD = 19;
1430
-
1431
- /** ExecServerMessage { id=1, exec_id=15, request_context_args=10, mcp_args=11, ... } */
1432
- export function decodeExecServerMessage(bytes) {
1433
- const reader = new Reader(bytes);
1434
- let id = 0;
1435
- let execId = "";
1436
- // Kept for the trace and for rejectionFor(): a server exec this build does
1437
- // not decode must still be answered, so its field number has to survive.
1438
- // `seen` lists every top-level field of the message, because an unknown
1439
- // exec can also carry non-oneof fields (ids, span context, tracing).
1440
- let unknownField;
1441
- const seen = [];
1442
- while (!reader.done) {
1443
- const { field, wireType } = reader.tag();
1444
- seen.push(field);
1445
- if (field === 1 && wireType === 0) {
1446
- id = reader.varint();
1447
- } else if (field === 15 && wireType === 2) {
1448
- execId = reader.string();
1449
- } else if (wireType === 2) {
1450
- const payload = reader.bytes();
1451
- if (field === 10) return { id, execId, case: "requestContextArgs" };
1452
- if (field === 11) return { id, execId, case: "mcpArgs", args: decodeMcpArgs(payload) };
1453
- if (field === 2) return { id, execId, case: "shellArgs", path: decodeSinglePathArg(payload) };
1454
- if (field === 3) return { id, execId, case: "writeArgs", path: decodeSinglePathArg(payload) };
1455
- if (field === 4) return { id, execId, case: "deleteArgs", path: decodeSinglePathArg(payload) };
1456
- if (field === 5) return { id, execId, case: "grepArgs" };
1457
- if (field === 7) return { id, execId, case: "readArgs", path: decodeReadArgsPath(payload) };
1458
- if (field === 8) return { id, execId, case: "lsArgs", path: decodeSinglePathArg(payload) };
1459
- if (field === 9) return { id, execId, case: "diagnosticsArgs" };
1460
- if (field === 14) return { id, execId, case: "shellStreamArgs", path: decodeSinglePathArg(payload) };
1461
- if (field === 16) return { id, execId, case: "backgroundShellSpawnArgs" };
1462
- if (field === 17) return { id, execId, case: "listMcpResourcesExecArgs" };
1463
- if (field === 18) return { id, execId, case: "readMcpResourceExecArgs" };
1464
- if (field === 20) return { id, execId, case: "fetchArgs", url: decodeFetchUrl(payload) };
1465
- if (field === 21) return { id, execId, case: "recordScreenArgs" };
1466
- if (field === 22) return { id, execId, case: "computerUseArgs" };
1467
- if (field === 23) return { id, execId, case: "writeShellStdinArgs" };
1468
- if (field !== EXEC_SPAN_CONTEXT_FIELD) unknownField ??= field;
1469
- } else {
1470
- reader.skip(wireType);
1471
- }
1472
- }
1473
- return { id, execId, case: "unknown", field: unknownField, seen };
1474
- }
1475
-
1476
- function decodeSinglePathArg(bytes) {
1477
- const reader = new Reader(bytes);
1478
- while (!reader.done) {
1479
- const { field, wireType } = reader.tag();
1480
- if (field === 1 && wireType === 2) return reader.string();
1481
- reader.skip(wireType);
1482
- }
1483
- return "";
1484
- }
1485
-
1486
- function decodeReadArgsPath(bytes) {
1487
- return decodeSinglePathArg(bytes);
1488
- }
1489
-
1490
- function decodeFetchUrl(bytes) {
1491
- const reader = new Reader(bytes);
1492
- while (!reader.done) {
1493
- const { field, wireType } = reader.tag();
1494
- if (field === 1 && wireType === 2) return reader.string();
1495
- reader.skip(wireType);
1496
- }
1497
- return "";
1498
- }
1499
-
1500
- /** McpArgs { name=1, args=2 (map<string,bytes>), tool_call_id=3, provider_identifier=4, tool_name=5 } */
1501
- export function decodeMcpArgs(bytes) {
1502
- const reader = new Reader(bytes);
1503
- let name = "";
1504
- let toolCallId = "";
1505
- let providerIdentifier = "";
1506
- let toolName = "";
1507
- const args = {};
1508
- while (!reader.done) {
1509
- const { field, wireType } = reader.tag();
1510
- if (field === 1 && wireType === 2) name = reader.string();
1511
- else if (field === 2 && wireType === 2) {
1512
- const entry = new Reader(reader.bytes());
1513
- let key = "";
1514
- let value;
1515
- while (!entry.done) {
1516
- const tag = entry.tag();
1517
- if (tag.field === 1 && tag.wireType === 2) key = entry.string();
1518
- else if (tag.field === 2 && tag.wireType === 2) value = entry.bytes();
1519
- else entry.skip(tag.wireType);
1520
- }
1521
- if (key) args[key] = value;
1522
- } else if (field === 3 && wireType === 2) toolCallId = reader.string();
1523
- else if (field === 4 && wireType === 2) providerIdentifier = reader.string();
1524
- else if (field === 5 && wireType === 2) toolName = reader.string();
1525
- else reader.skip(wireType);
1526
- }
1527
- return { name, toolCallId, providerIdentifier, toolName, args };
1528
- }
1529
-
1530
- /** GetUsableModelsResponse { models = 1: repeated ModelDetails } */
1531
- export function decodeUsableModels(bytes) {
1532
- const reader = new Reader(bytes);
1533
- const models = [];
1534
- while (!reader.done) {
1535
- const { field, wireType } = reader.tag();
1536
- if (field === 1 && wireType === 2) {
1537
- const model = decodeModelDetails(reader.bytes());
1538
- if (model?.id) models.push(model);
1539
- } else {
1540
- reader.skip(wireType);
1541
- }
1542
- }
1543
- return models;
1544
- }
1545
-
1546
- /** ModelDetails { model_id=1, display_model_id=3, display_name=4, display_name_short=5, aliases=6 } */
1547
- function decodeModelDetails(bytes) {
1548
- const reader = new Reader(bytes);
1549
- const model = { id: "", name: "" };
1550
- while (!reader.done) {
1551
- const { field, wireType } = reader.tag();
1552
- if (wireType === 2) {
1553
- if (field === 1) model.id = reader.string();
1554
- else if (field === 3) model.displayModelId = reader.string();
1555
- else if (field === 4) model.name = reader.string();
1556
- else if (field === 5) model.displayNameShort = reader.string();
1557
- else if (field === 6) reader.bytes(); // aliases
1558
- else reader.bytes();
1559
- } else {
1560
- reader.skip(wireType);
1561
- }
1562
- }
1563
- return model;
1564
- }
1565
- //#endregion
1566
-
1567
- //#region Connect framing
1568
- /** Connect frame: [1-byte flags][4-byte big-endian length][payload]. */
1569
- export function frameEncode(payload, flags = 0) {
1570
- const out = new Uint8Array(5 + payload.length);
1571
- const view = new DataView(out.buffer);
1572
- view.setUint8(0, flags);
1573
- view.setUint32(1, payload.length, false);
1574
- out.set(payload, 5);
1575
- return out;
1576
- }
1577
-
1578
- export const CONNECT_END_STREAM_FLAG = 0b00000010;
1579
- export const CONNECT_COMPRESSED_FLAG = 0b00000001;
1580
-
1581
- /** Upper bound for a decompressed Connect frame; guards against zip bombs. */
1582
- export const MAX_CONNECT_FRAME_BYTES = 64 * 1024 * 1024;
1583
-
1584
- /**
1585
- * Incremental Connect frame parser fed by response chunks.
1586
- * Yields { flags, payload } objects. Frames flagged compressed (bit 0) are
1587
- * gzip-decompressed here so consumers only ever see plain payloads.
1588
- */
1589
- export class ConnectFrameReader {
1590
- constructor() {
1591
- this.buffer = new Uint8Array(0);
1592
- this.frames = [];
1593
- this.waiters = [];
1594
- this.ended = false;
1595
- this.paused = false;
1596
- this.error = undefined;
1597
- }
1598
-
1599
- push(chunk) {
1600
- const combined = new Uint8Array(this.buffer.length + chunk.length);
1601
- combined.set(this.buffer);
1602
- combined.set(chunk, this.buffer.length);
1603
- this.buffer = combined;
1604
- while (this.buffer.length >= 5) {
1605
- const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, 5);
1606
- const flags = view.getUint8(0);
1607
- const length = view.getUint32(1, false);
1608
- if (this.buffer.length < 5 + length) break;
1609
- const payload = this.buffer.slice(5, 5 + length);
1610
- this.buffer = this.buffer.slice(5 + length);
1611
- let data = payload;
1612
- if ((flags & CONNECT_COMPRESSED_FLAG) !== 0) {
1613
- try {
1614
- data = gunzipSync(Buffer.from(payload), { maxOutputLength: MAX_CONNECT_FRAME_BYTES });
1615
- } catch (error) {
1616
- this.fail(new Error("Cursor sent an unreadable compressed frame", { cause: error }));
1617
- return;
1618
- }
1619
- }
1620
- this.#enqueue({ flags: flags & ~CONNECT_COMPRESSED_FLAG, payload: data });
1621
- }
1622
- }
1623
-
1624
- #enqueue(frame) {
1625
- if (this.waiters.length > 0) {
1626
- const waiter = this.waiters.shift();
1627
- waiter.resolve(frame);
1628
- return;
1629
- }
1630
- this.frames.push(frame);
1631
- }
1632
-
1633
- finish() {
1634
- this.ended = true;
1635
- for (const waiter of this.waiters.splice(0)) waiter.resolve(undefined);
1636
- }
1637
-
1638
- /**
1639
- * Stop blocking for new frames without ending the stream: buffered frames
1640
- * are still returned, and `resume()` restores normal waiting. A Cursor run
1641
- * stays alive across DSH steps, so a reader that is paused to end one step
1642
- * must accept frames again when the next step continues the same run.
1643
- */
1644
- pause() {
1645
- this.paused = true;
1646
- for (const waiter of this.waiters.splice(0)) waiter.resolve(undefined);
1647
- }
1648
-
1649
- resume() {
1650
- this.paused = false;
1651
- }
1652
-
1653
- fail(error) {
1654
- this.error = error;
1655
- this.ended = true;
1656
- for (const waiter of this.waiters.splice(0)) waiter.reject(error);
1657
- }
1658
-
1659
- async next() {
1660
- if (this.frames.length > 0) return this.frames.shift();
1661
- if (this.paused) return undefined;
1662
- if (this.ended) {
1663
- if (this.error !== undefined) throw this.error;
1664
- return undefined;
1665
- }
1666
- return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
1667
- }
1668
- }
1669
- //#endregion
1670
-
1671
- //#region agent transport
1672
- const AGENT_HEADERS = {
1673
- "content-type": "application/connect+proto",
1674
- "connect-protocol-version": "1",
1675
- "connect-accept-encoding": "gzip",
1676
- te: "trailers",
1677
- "x-ghost-mode": "true",
1678
- "x-cursor-client-version": CURSOR_CLIENT_VERSION,
1679
- "x-cursor-client-type": "cli",
1680
- };
1681
-
1682
- /**
1683
- * One bidirectional agent run over HTTP/2. Writes Connect-framed
1684
- * `AgentClientMessage`s and surfaces `AgentServerMessage`s through the frame
1685
- * reader. Heartbeats keep the run alive.
1686
- */
1687
- export class AgentRun { constructor(accessToken, options = {}) {
1688
- this.accessToken = accessToken;
1689
- this.baseUrl = options.baseUrl ?? CURSOR_BASE_URL;
1690
- this.session = undefined;
1691
- this.frames = new ConnectFrameReader();
1692
- this.responseStatus = undefined;
1693
- this.responseContentType = undefined;
1694
- this.trailers = {};
1695
- this.finished = false;
1696
- }
1697
-
1698
- async start() {
1699
- const url = new URL(CURSOR_RUN_PATH, this.baseUrl);
1700
- const headers = {
1701
- ":method": "POST",
1702
- ":path": url.pathname,
1703
- authorization: `Bearer ${this.accessToken}`,
1704
- ...AGENT_HEADERS,
1705
- };
1706
- this.responsePromise = new Promise((resolve, reject) => {
1707
- this.resolveResponse = resolve;
1708
- this.rejectResponse = reject;
1709
- });
1710
- // The original promise remains rejectable for waitForResponse(); this
1711
- // observer only prevents a close-before-wait race from becoming unhandled.
1712
- void this.responsePromise.catch(() => {});
1713
- this.session = http2.connect(this.baseUrl);
1714
- this.stream = this.session.request(headers);
1715
- // NOTE: never call stream.setEncoding() here — Node's setEncoding(null)
1716
- // still decodes binary frames as UTF-8, corrupting any non-UTF-8 byte
1717
- // (e.g. blob ids) into U+FFFD replacement characters.
1718
- this.stream.on("response", (headers) => {
1719
- this.responseStatus = Number(headers[":status"]);
1720
- this.responseContentType = typeof headers["content-type"] === "string" ? headers["content-type"] : undefined;
1721
- this.#resolveResponse(this.responseStatus);
1722
- });
1723
- this.stream.on("data", (chunk) => this.frames.push(Buffer.from(chunk)));
1724
- this.stream.on("trailers", (trailers) => {
1725
- this.trailers = trailers;
1726
- });
1727
- this.stream.on("end", () => this.frames.finish());
1728
- this.stream.on("close", () => {
1729
- if (this.responseStatus === undefined) this.#rejectResponse(new Error("Cursor HTTP stream closed before response"));
1730
- });
1731
- this.stream.on("error", (error) => {
1732
- this.#rejectResponse(error);
1733
- this.frames.fail(error);
1734
- });
1735
- this.session.on("error", (error) => {
1736
- this.#rejectResponse(error);
1737
- this.frames.fail(error);
1738
- });
1739
- }
1740
-
1741
- #resolveResponse(status) {
1742
- const resolve = this.resolveResponse;
1743
- this.resolveResponse = undefined;
1744
- this.rejectResponse = undefined;
1745
- resolve?.(status);
1746
- }
1747
-
1748
- #rejectResponse(error) {
1749
- const reject = this.rejectResponse;
1750
- this.resolveResponse = undefined;
1751
- this.rejectResponse = undefined;
1752
- reject?.(error);
1753
- }
1754
-
1755
- waitForResponse(timeoutMs) {
1756
- if (this.responsePromise === undefined) throw new Error("Cursor AgentRun has not started");
1757
- if (timeoutMs === undefined) return this.responsePromise;
1758
- return new Promise((resolve, reject) => {
1759
- const timer = setTimeout(() => reject(new Error("Cursor HTTP response timeout")), timeoutMs);
1760
- timer.unref?.();
1761
- this.responsePromise.then(resolve, reject).finally(() => clearTimeout(timer));
1762
- });
1763
- }
1764
-
1765
- write(payload) {
1766
- if (this.finished || this.stream === undefined || this.stream.destroyed) return false;
1767
- try {
1768
- this.stream.write(Buffer.from(payload));
1769
- return true;
1770
- } catch {
1771
- return false;
1772
- }
1773
- }
1774
-
1775
- writeMessage(bytes) {
1776
- return this.write(frameEncode(bytes));
1777
- }
1778
-
1779
- startHeartbeat() {
1780
- this.heartbeat = setInterval(() => {
1781
- this.writeMessage(encodeHeartbeat());
1782
- }, HEARTBEAT_INTERVAL_MS);
1783
- this.heartbeat.unref?.();
1784
- }
1785
-
1786
- /** Fail the run with a terminal error and tear down the connection. */
1787
- abort(error) {
1788
- if (this.finished) return;
1789
- this.#rejectResponse(error);
1790
- this.frames.fail(error);
1791
- this.close();
1792
- }
1793
-
1794
- close() {
1795
- if (this.finished) return;
1796
- this.#rejectResponse(new Error("Cursor AgentRun closed before response"));
1797
- this.finished = true;
1798
- if (this.heartbeat !== undefined) clearInterval(this.heartbeat);
1799
- if (!this.frames.ended) this.frames.finish();
1800
- try {
1801
- this.stream?.close();
1802
- } catch {}
1803
- // Force-destroy the session so no socket keeps the process alive after a
1804
- // completed run; the agent response is fully consumed by then.
1805
- try {
1806
- this.session.destroy();
1807
- } catch {}
1808
- }
1809
- }
1810
- //#endregion
1811
-
1812
- //#region conversation building
1813
- function sha256(bytes) {
1814
- return createHash("sha256").update(bytes).digest();
1815
- }
1816
-
1817
- function collectImageAttachments(options) {
1818
- const refs = new Map();
1819
- const visit = (blocks) => {
1820
- for (const block of blocks ?? []) {
1821
- if (block.type === "image") refs.set(block.attachment.attachmentId, block.attachment);
1822
- else if (block.type === "tool-result") visit(block.content);
1823
- }
1824
- };
1825
- for (const message of options.messages ?? []) if (message.role === "user") visit(message.content);
1826
- return [...refs.values()];
1827
- }
1828
-
1829
- export async function prepareCursorImages(options, attachments) {
1830
- const refs = collectImageAttachments(options);
1831
- if (refs.length === 0) return [];
1832
- if (attachments === undefined) throw new LlmError("Cursor image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1833
- const stored = await Promise.all(refs.map((ref) => attachments.readImage(ref, options.signal)));
1834
- return stored.map(({ ref, data }) => ({ data: Buffer.from(data).toString("base64"), mimeType: ref.mediaType }));
1835
- }
1836
-
1837
- function flattenBlocks(content) {
1838
- let text = "";
1839
- for (const block of content ?? []) {
1840
- if (block.type === "text") text += block.text;
1841
- else if (block.type === "tool-result") {
1842
- const callId = block.toolCallId ? ` for ${block.toolCallId}` : "";
1843
- text += `\n[TOOL RESULT${callId}]\n${flattenBlocks(block.content)}`;
1844
- }
1845
- }
1846
- return text;
1847
- }
1848
-
1849
- function renderAssistant(content) {
1850
- let text = "";
1851
- for (const block of content ?? []) {
1852
- if (block.type === "text") text += block.text;
1853
- else if (block.type === "tool-call") {
1854
- text += `\n<tool_call id="${block.id}" name="${block.name}">${block.arguments}</tool_call>`;
1855
- } else if (block.type === "reasoning") {
1856
- text += `\n<thinking>${block.text}</thinking>`;
1857
- }
1858
- }
1859
- return text.trim();
1860
- }
1861
-
1862
- function sanitizeReplayedAssistantText(text) {
1863
- let omitted = false;
1864
- const sanitized = String(text ?? "").replace(/<tool_call\b[\s\S]*?<\/tool_call>/gi, () => {
1865
- omitted = true;
1866
- return "";
1867
- }).replace(/<\/?invoke>|<\|eos\|>/gi, "").trim();
1868
- if (!omitted) return sanitized;
1869
- return `${sanitized}${sanitized ? "\n" : ""}[Previous textual tool-call markup omitted from replay.]`;
1870
- }
1871
-
1872
- /**
1873
- * How much of a replayed tool request to keep. Enough for the arguments that are
1874
- * state — a plan (`todo_write`) is its list, and a repeated `grep` is avoided
1875
- * only if the model can see what it already asked for — while a `write` of a
1876
- * whole file stays bounded.
1877
- */
1878
- const REPLAYED_CALL_CHARS = 600;
1879
-
1880
- /** Replay assistant history without teaching models to imitate XML tool tags. */
1881
- function renderColdStartAssistant(content) {
1882
- const parts = [];
1883
- for (const block of content ?? []) {
1884
- if (block.type === "text") {
1885
- const text = sanitizeReplayedAssistantText(block.text);
1886
- if (text) parts.push(text);
1887
- } else if (block.type === "tool-call") {
1888
- // The request's arguments are part of the history, not decoration: a
1889
- // replay that keeps only the tool name leaves the model unable to
1890
- // recall the plan it wrote or the search it already ran, and it
1891
- // re-derives both — reworded, which is what plan churn looks like.
1892
- const args = typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments ?? "");
1893
- const shown = args.length > REPLAYED_CALL_CHARS ? `${args.slice(0, REPLAYED_CALL_CHARS)}…` : args;
1894
- parts.push(`[Previous tool request: ${block.name}${shown.length > 0 ? ` ${shown}` : ""}]`);
1895
- }
1896
- }
1897
- return parts.join("\n").trim();
1898
- }
1899
-
1900
- function actionBoundary(messages) {
1901
- for (let index = messages.length - 1; index >= 0; index--) {
1902
- if (messages[index].role === "assistant") return index + 1;
1903
- }
1904
- return 0;
1905
- }
1906
-
1907
- /** Combine the current DSH turn's adjacent user/context messages into one action. */
1908
- function currentActionText(options) {
1909
- const messages = options.messages ?? [];
1910
- const boundary = actionBoundary(messages);
1911
- const parts = [];
1912
- for (let index = boundary; index < messages.length; index++) {
1913
- const message = messages[index];
1914
- if (message.role !== "user") continue;
1915
- const text = flattenBlocks(message.content).trim();
1916
- if (text.length > 0) parts.push(text);
1917
- }
1918
- if (parts.length > 0) return parts.join("\n\n");
1919
- for (let index = messages.length - 1; index >= 0; index--) {
1920
- if (messages[index].role !== "user") continue;
1921
- const text = flattenBlocks(messages[index].content).trim();
1922
- if (text.length > 0) return text;
1923
- }
1924
- return "";
1925
- }
1926
-
1927
- /**
1928
- * Markers this harness leaves in a result when the sandbox refused the work.
1929
- *
1930
- * They call for different answers, and telling them apart matters: the
1931
- * `SetNamedSecurityInfoW` failure is the ACL grant the confinement itself needs
1932
- * and cannot get, so *no* narrower mode can start and only a mode without that
1933
- * grant can run the call. `[sandbox: ... denied under <mode> mode]` is a
1934
- * refusal from a sandbox that did start, where one step wider may well suffice.
1935
- * Naming the narrowest mode first for the ACL failure is what turned a single
1936
- * refusal into a retry loop: the model asked for `workspace-write` — the mode it
1937
- * was already in — and failed the same way 101 times in one session.
1938
- */
1939
- const SANDBOX_ACL_FAILURE = /SetNamedSecurityInfoW failed/i;
1940
- const SANDBOX_POLICY_DENIAL = /\[sandbox:[^\]]*denied/i;
1941
- /**
1942
- * DSH's escalation rule, restated where the refusal lands.
1943
- *
1944
- * Every provider gets this rule — retry the denied call once with
1945
- * `sandbox_permissions` and a justification, which raises an approval prompt —
1946
- * but only the `pwsh` description spells it out, and the refusal result carries
1947
- * nothing at all. A model that does not recall the rule from a tool description
1948
- * reads the refusal as "this tool does not work here" and stops using it, which
1949
- * is how a Cursor turn ends up saying local tools are unavailable instead of
1950
- * asking the user for permission.
1951
- */
1952
- const SANDBOX_ACL_HINT =
1953
- "\n\n[cursor] The DSH sandbox cannot start in this workspace — its own ACL grant fails "
1954
- + "(`SetNamedSecurityInfoW`) — so no narrower mode can succeed. Retry this exact call once with "
1955
- + "sandbox_permissions=\"danger-full-access\" and a one-sentence justification; DSH then asks the user to "
1956
- + "approve it. Do not retry it in a narrower mode, and if the retry is refused, stop and report the refusal "
1957
- + "instead of trying again.";
1958
- const SANDBOX_POLICY_HINT =
1959
- "\n\n[cursor] The DSH sandbox refused this call, and that is recoverable: retry this exact call once with "
1960
- + "sandbox_permissions set to the narrowest wider mode that suffices (workspace-write, or danger-full-access "
1961
- + "when that is not enough) plus a one-sentence justification. DSH then asks the user to approve it. If the "
1962
- + "retry is refused as well, stop and report it instead of trying another mode.";
1963
-
1964
- /** Whether one result is the harness refusing, rather than the tool failing. */
1965
- function isSandboxRefusal(content) {
1966
- return SANDBOX_ACL_FAILURE.test(content) || SANDBOX_POLICY_DENIAL.test(content);
1967
- }
1968
-
1969
- /**
1970
- * Result text as the model should read it: a sandbox refusal gains the rule that
1971
- * turns it back into a request the user can approve.
1972
- * @param content Result text on its way to the model.
1973
- * @returns The text, with the escalation rule a refusal calls for appended.
1974
- */
1975
- function withSandboxHint(content) {
1976
- if (content.includes("sandbox_permissions")) return content;
1977
- if (SANDBOX_ACL_FAILURE.test(content)) return `${content}${SANDBOX_ACL_HINT}`;
1978
- if (SANDBOX_POLICY_DENIAL.test(content)) return `${content}${SANDBOX_POLICY_HINT}`;
1979
- return content;
1980
- }
1981
-
1982
- /** Extract DSH tool-result blocks for resuming a live Cursor exec bridge. */
1983
- function collectToolResults(options) {
1984
- const results = [];
1985
- for (const message of options.messages ?? []) {
1986
- for (const block of message.content ?? []) {
1987
- if (block.type !== "tool-result") continue;
1988
- const content = flattenBlocks(block.content).trim();
1989
- const sandboxDenied = isSandboxRefusal(content);
1990
- results.push({
1991
- toolCallId: block.toolCallId ?? message.source?.callId,
1992
- content: withSandboxHint(content),
1993
- isError: block.isError === true,
1994
- sandboxDenied,
1995
- });
1996
- }
1997
- }
1998
- return results;
1999
- }
2000
-
2001
- /**
2002
- * The compaction DSH applied to the history carried by one request, if any.
2003
- *
2004
- * DSH frees context by replacing a span of the conversation with a summary and
2005
- * marking that message `source.kind = "compact-checkpoint"` with the id of the
2006
- * compaction. The adapter compares that id with the one its persisted Cursor
2007
- * conversation was built from: equal means the checkpoint still describes the
2008
- * history DSH is sending, a new id means DSH has rewritten it.
2009
- *
2010
- * @param options The DSH request whose message history is being sent.
2011
- * @returns The newest compaction id in the history, or undefined when it has none.
2012
- */
2013
- function compactionIdOf(options) {
2014
- let id;
2015
- for (const message of options.messages ?? []) {
2016
- if (message.source?.kind !== "compact-checkpoint") continue;
2017
- id = message.source.compactionId ?? "compact-checkpoint";
2018
- }
2019
- return id;
2020
- }
2021
-
2022
- function decodeXmlText(value) {
2023
- return String(value ?? "")
2024
- .replace(/&quot;/gi, '"')
2025
- .replace(/&apos;/gi, "'")
2026
- .replace(/&lt;/gi, "<")
2027
- .replace(/&gt;/gi, ">")
2028
- .replace(/&amp;/gi, "&");
2029
- }
2030
-
2031
- function parseTagAttributes(source) {
2032
- const attributes = {};
2033
- const pattern = /([A-Za-z_][\w-]*)\s*=\s*"([\s\S]*?)"/g;
2034
- for (let match; (match = pattern.exec(source)) !== null;) attributes[match[1]] = decodeXmlText(match[2]);
2035
- return attributes;
2036
- }
2037
-
2038
- function resolveTextTool(candidate, tools) {
2039
- const available = tools ?? [];
2040
- const normalized = String(candidate ?? "").toLowerCase();
2041
- let tool = available.find((entry) => {
2042
- const name = String(entry.name).toLowerCase();
2043
- return normalized === name || normalized.endsWith(`_${name}`);
2044
- });
2045
- if (tool !== undefined) return tool;
2046
- const aliases = {
2047
- shell: ["pwsh", "bash"],
2048
- read: ["read"],
2049
- glob: ["glob"],
2050
- grep: ["grep"],
2051
- write: ["write"],
2052
- edit: ["edit"],
2053
- };
2054
- for (const name of aliases[normalized] ?? []) {
2055
- tool = available.find((entry) => String(entry.name).toLowerCase() === name);
2056
- if (tool !== undefined) return tool;
2057
- }
2058
- return undefined;
2059
- }
2060
-
2061
- function normalizeTextToolArguments(tool, raw) {
2062
- const args = { ...raw };
2063
- if (tool.name === "read" && args.file_path === undefined && args.path !== undefined) {
2064
- args.file_path = args.path;
2065
- delete args.path;
2066
- }
2067
- if (tool.name === "glob") {
2068
- if (args.pattern === undefined && args.glob_pattern !== undefined) args.pattern = args.glob_pattern;
2069
- if (args.path === undefined && args.target_directory !== undefined) args.path = args.target_directory;
2070
- delete args.glob_pattern;
2071
- delete args.target_directory;
2072
- }
2073
- for (const [key, value] of Object.entries(args)) {
2074
- const type = tool.parameters?.properties?.[key]?.type;
2075
- if ((type === "number" || type === "integer") && typeof value === "string" && value.trim() !== "") {
2076
- const number = Number(value);
2077
- if (Number.isFinite(number)) args[key] = number;
2078
- } else if (type === "boolean" && typeof value === "string") {
2079
- if (value.toLowerCase() === "true") args[key] = true;
2080
- else if (value.toLowerCase() === "false") args[key] = false;
2081
- } else if ((type === "object" || type === "array") && typeof value === "string") {
2082
- try { args[key] = JSON.parse(value); } catch {}
2083
- }
2084
- }
2085
- return args;
2086
- }
2087
-
2088
- /**
2089
- * Recover Cursor models that print tool-call XML as text instead of emitting
2090
- * an execServerMessage.mcpArgs frame. This is a fallback for contaminated or
2091
- * compacted conversations; protocol-native MCP calls remain the primary path.
2092
- */
2093
- export function parseTextToolCalls(text, tools) {
2094
- const calls = [];
2095
- const pattern = /<tool_call\b([^>]*)>([\s\S]*?)<\/tool_call>/gi;
2096
- for (let match; (match = pattern.exec(String(text ?? ""))) !== null;) {
2097
- const attributes = parseTagAttributes(match[1]);
2098
- const tool = resolveTextTool(attributes.name ?? attributes.id, tools);
2099
- if (tool === undefined) continue;
2100
- const rawArgs = {};
2101
- const parameterPattern = /<parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/parameter>/gi;
2102
- for (let parameter; (parameter = parameterPattern.exec(match[2])) !== null;) {
2103
- rawArgs[parameter[1]] = decodeXmlText(parameter[2].trim());
2104
- }
2105
- for (const [key, value] of Object.entries(attributes)) {
2106
- if (key !== "id" && key !== "name") rawArgs[key] = value;
2107
- }
2108
- if (Object.keys(rawArgs).length === 0) {
2109
- const body = decodeXmlText(match[2]).trim();
2110
- if (body.startsWith("{")) {
2111
- try { Object.assign(rawArgs, JSON.parse(body)); } catch {}
2112
- }
2113
- }
2114
- calls.push({
2115
- id: `text-tool-${crypto.randomUUID()}`,
2116
- name: tool.name,
2117
- arguments: JSON.stringify(normalizeTextToolArguments(tool, rawArgs)),
2118
- });
2119
- }
2120
- return calls;
2121
- }
2122
-
2123
- function coldStartLabel(message) {
2124
- if (message.role === "assistant") return "ASSISTANT";
2125
- if ((message.content ?? []).some((block) => block.type === "tool-result")) return "TOOL RESULT";
2126
- if (message.source?.kind === "plugin") return "RUNTIME CONTEXT";
2127
- return "USER";
2128
- }
2129
-
2130
- /**
2131
- * Rehydrate a DSH conversation after process restart without using Cursor's
2132
- * field-8 turns (the current server interprets those entries as blob ids).
2133
- */
2134
- function coldStartActionText(options) {
2135
- const entries = [];
2136
- for (const message of options.messages ?? []) {
2137
- if (message.role === "system") continue;
2138
- const text = message.role === "assistant" ? renderColdStartAssistant(message.content) : withSandboxHint(flattenBlocks(message.content).trim());
2139
- if (text.length === 0) continue;
2140
- entries.push({ label: coldStartLabel(message), text });
2141
- }
2142
- if (entries.length === 0) return "";
2143
- if (entries.length === 1 && entries[0].label === "USER") return entries[0].text;
2144
- return [
2145
- "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.",
2146
- ...entries.map((entry) => `[${entry.label}]\n${entry.text}`),
2147
- ].join("\n\n");
2148
- }
2149
-
2150
- /**
2151
- * Build a fresh state with root prompt blobs only. Current Cursor servers reject
2152
- * hand-encoded field-8 turns, so cold starts carry textual history in the action.
2153
- */
2154
- export function buildInitialConversationState(options) {
2155
- const systemParts = [options.system ?? ""];
2156
- for (const message of options.messages ?? []) {
2157
- if (message.role === "system") systemParts.push(flattenBlocks(message.content));
2158
- }
2159
- const systemText = systemParts.filter((part) => part.length > 0).join("\n").trim();
2160
- const blobStore = new Map();
2161
- const rootPromptBlobIds = [];
2162
- if (systemText.length > 0) {
2163
- const payload = textEncoder.encode(JSON.stringify({ role: "system", content: systemText }));
2164
- const id = sha256(payload);
2165
- blobStore.set(Buffer.from(id).toString("hex"), payload);
2166
- rootPromptBlobIds.push(id);
2167
- }
2168
- return {
2169
- conversationState: encodeConversationState({ rootPromptBlobIds, turns: [], systemText }),
2170
- blobStore,
2171
- systemText,
2172
- };
2173
- }
2174
-
2175
- /**
2176
- * Fold DSH history into Cursor's turn-based conversation state.
2177
- *
2178
- * The last user message becomes the new `ConversationAction`; everything
2179
- * before it is the durable turn history. Tool results are rendered as user
2180
- * messages so a follow-up run after DSH executes a tool carries the result
2181
- * back to the model. The system prompt travels as a blob referenced by
2182
- * `rootPromptMessagesJson`; the server fetches it through the KV handshake.
2183
- */
2184
- export function buildConversationState(options) {
2185
- const systemParts = [options.system ?? ""];
2186
- const turns = [];
2187
- let currentUser = null;
2188
- let currentSteps = [];
2189
-
2190
- const flushTurn = () => {
2191
- if (currentUser !== null) {
2192
- const userBytes = encodeUserMessage({ text: currentUser, messageId: randomUUID() });
2193
- const turnBytes = encodeAgentTurn(userBytes, currentSteps);
2194
- turns.push(encodeTurnStructure(turnBytes));
2195
- }
2196
- currentUser = null;
2197
- currentSteps = [];
2198
- };
2199
-
2200
- for (const message of options.messages ?? []) {
2201
- if (message.role === "system") {
2202
- systemParts.push(flattenBlocks(message.content));
2203
- continue;
2204
- }
2205
- if (message.role === "user") {
2206
- flushTurn();
2207
- currentUser = flattenBlocks(message.content);
2208
- continue;
2209
- }
2210
- if (message.role === "assistant") {
2211
- const text = renderAssistant(message.content);
2212
- if (currentUser === null) currentUser = ""; // assistant-first history: implicit turn
2213
- if (text.length > 0) currentSteps.push(encodeAssistantStep(text));
2214
- }
2215
- }
2216
-
2217
- // The final pending user message is the action, not part of history.
2218
- let actionText = "";
2219
- if (currentUser !== null) {
2220
- actionText = currentUser;
2221
- }
2222
- currentUser = null;
2223
- currentSteps = [];
2224
-
2225
- const systemText = systemParts.filter((part) => part.length > 0).join("\n").trim();
2226
- const blobStore = new Map();
2227
- const rootPromptBlobIds = [];
2228
- if (systemText.length > 0) {
2229
- const payload = textEncoder.encode(JSON.stringify({ role: "system", content: systemText }));
2230
- const id = sha256(payload);
2231
- blobStore.set(Buffer.from(id).toString("hex"), payload);
2232
- rootPromptBlobIds.push(id);
2233
- }
2234
-
2235
- const conversationState = encodeConversationState({ rootPromptBlobIds, turns, systemText });
2236
- const userBytes = encodeUserMessage({ text: actionText, messageId: randomUUID() });
2237
- const action = encodeUserMessageAction(userBytes);
2238
- return { conversationState, action, blobStore, systemText };
2239
- }
2240
-
2241
- /**
2242
- * Build the full `AgentClientMessage` payload for one run.
2243
- *
2244
- * When a persisted checkpoint for this conversation exists (captured from a
2245
- * previous run's `conversation_checkpoint_update`), it becomes the
2246
- * conversation state and its referenced blobs are served from the persisted
2247
- * blob store. Otherwise the state is built from the DSH message history.
2248
- */
2249
- export function buildRunPayload(options, modelId, persisted, images = []) {
2250
- let conversationState;
2251
- let blobStore;
2252
- let actionText;
2253
- if (persisted?.checkpoint !== undefined) {
2254
- // The checkpoint already encodes prior turns. DSH may append both the
2255
- // human request and a runtime-context user message, so combine the whole
2256
- // trailing user group rather than selecting only the final message.
2257
- conversationState = persisted.checkpoint;
2258
- blobStore = persisted.blobs ?? new Map();
2259
- actionText = currentActionText(options);
2260
- } else {
2261
- // On first use or after a DSH restart there is no server checkpoint. Never
2262
- // hand-encode field-8 turns: current Cursor servers treat them as blob ids.
2263
- const built = buildInitialConversationState(options);
2264
- conversationState = built.conversationState;
2265
- blobStore = built.blobStore;
2266
- actionText = coldStartActionText(options);
2267
- }
2268
- const userBytes = encodeUserMessage({ text: actionText, messageId: randomUUID() });
2269
- const action = encodeUserMessageAction(userBytes, images);
2270
- const modelDetails = encodeModelDetails(modelId);
2271
- const runRequest = encodeRunRequest({
2272
- conversationState,
2273
- action,
2274
- modelDetails,
2275
- conversationId: randomUUID(),
2276
- });
2277
- return { payload: encodeRunMessage(runRequest), blobStore };
2278
- }
2279
-
2280
- //#endregion
2281
-
2282
- //#region models discovery
2283
- /**
2284
- * Fetch usable models from Cursor's unary `GetUsableModels` endpoint.
2285
- * The request body is the raw (unframed) empty protobuf message.
2286
- */
2287
- export async function fetchUsableModels(accessToken, options = {}) {
2288
- const fetchImpl = options.fetch ?? nativeFetch;
2289
- const response = await fetchImpl(`${CURSOR_BASE_URL}${CURSOR_MODELS_PATH}`, {
2290
- method: "POST",
2291
- redirect: "error",
2292
- headers: {
2293
- "content-type": "application/proto",
2294
- authorization: `Bearer ${accessToken}`,
2295
- "x-ghost-mode": "true",
2296
- "x-cursor-client-version": CURSOR_CLIENT_VERSION,
2297
- "x-cursor-client-type": "cli",
2298
- "user-agent": "dsh-cursor-subscription/0.1.0",
2299
- },
2300
- body: new Uint8Array(0),
2301
- signal: options.signal,
2302
- });
2303
- if (!response.ok) throw new Error(`Cursor model discovery failed (HTTP ${response.status})`);
2304
- const bytes = new Uint8Array(await response.arrayBuffer());
2305
- if (bytes.length === 0) throw new Error("Cursor model discovery returned an empty response");
2306
- // The body may be raw protobuf or Connect-framed; try both.
2307
- const models = decodeUsableModels(bytes);
2308
- if (models.length > 0) return models;
2309
- const frameReader = new ConnectFrameReader();
2310
- frameReader.push(bytes);
2311
- frameReader.finish();
2312
- const framed = [];
2313
- for (;;) {
2314
- const frame = await frameReader.next();
2315
- if (frame === undefined) break;
2316
- if ((frame.flags & CONNECT_END_STREAM_FLAG) !== 0) break;
2317
- framed.push(...decodeUsableModels(frame.payload));
2318
- }
2319
- return framed;
2320
- }
2321
- //#endregion
2322
-
2323
- //#region usage
2324
- const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2325
-
2326
- /**
2327
- * Extract the legacy request bucket from the `/api/usage` model map. Cursor's
2328
- * dashboard hardcodes the `"gpt-4"` entry for the legacy quota; when absent we
2329
- * fall back to the quota-bearing bucket with the largest limit.
2330
- */
2331
- export function parseLegacyBucket(json) {
2332
- if (!record(json)) return undefined;
2333
- const gpt4 = json["gpt-4"];
2334
- if (record(gpt4) && typeof gpt4.numRequests === "number") {
2335
- return { numRequests: gpt4.numRequests, maxRequestUsage: gpt4.maxRequestUsage };
2336
- }
2337
- let best;
2338
- let bestLimit = 0;
2339
- for (const value of Object.values(json)) {
2340
- if (record(value) && typeof value.numRequests === "number") {
2341
- const limit = typeof value.maxRequestUsage === "number" ? value.maxRequestUsage : 0;
2342
- if (limit > bestLimit) {
2343
- bestLimit = limit;
2344
- best = { numRequests: value.numRequests, maxRequestUsage: value.maxRequestUsage };
2345
- }
2346
- }
2347
- }
2348
- return best;
2349
- }
2350
-
2351
- /** Cursor converts included on-plan spend (cents) to requests at ~4 cents each. */
2352
- export function getRequestCountFromSpendCents(cents) {
2353
- return typeof cents === "number" && cents > 0 ? Math.ceil(cents / 4) : 0;
2354
- }
2355
-
2356
- /**
2357
- * Compute included-request usage the way Cursor's dashboard does:
2358
- * `used = team ? ceil(spendCents/4) : legacy.numRequests`,
2359
- * `limit = team ? 500 * requestQuotaPerSeat : legacy.maxRequestUsage`.
2360
- */
2361
- export function computeIncludedRequests({ legacy, isTeam, planUsedCents, requestQuotaPerSeat }) {
2362
- const usedFromSpend =
2363
- typeof planUsedCents === "number" && planUsedCents > 0 ? getRequestCountFromSpendCents(planUsedCents) : undefined;
2364
- const used = isTeam ? usedFromSpend ?? legacy?.numRequests : legacy?.numRequests;
2365
- const limit = isTeam && typeof requestQuotaPerSeat === "number" ? 500 * requestQuotaPerSeat : legacy?.maxRequestUsage;
2366
- if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return undefined;
2367
- return {
2368
- used,
2369
- limit,
2370
- remaining: Math.max(0, limit - used),
2371
- pct: Math.round((used / limit) * 1000) / 10,
2372
- };
2373
- }
2374
-
2375
- function centsToDollars(cents) {
2376
- return typeof cents === "number" && Number.isFinite(cents) ? Math.round((cents / 100) * 100) / 100 : undefined;
2377
- }
2378
-
2379
- function asFiniteNumber(value) {
2380
- if (typeof value === "number" && Number.isFinite(value)) return value;
2381
- if (typeof value === "string" && value.trim() !== "") {
2382
- const n = Number(value);
2383
- if (Number.isFinite(n)) return n;
2384
- }
2385
- return undefined;
2386
- }
2387
-
2388
- function firstFinite(...values) {
2389
- for (const value of values) {
2390
- const n = asFiniteNumber(value);
2391
- if (n !== undefined) return n;
2392
- }
2393
- return undefined;
2394
- }
2395
-
2396
- function percentFromUsageMessage(message) {
2397
- if (typeof message !== "string") return undefined;
2398
- const match = message.match(/(\d+(?:\.\d+)?)\s*%/);
2399
- return match ? asFiniteNumber(match[1]) : undefined;
2400
- }
2401
-
2402
- /** Project `/api/usage-summary` into browser-safe usage facts. */
2403
- export function parseUsageSummary(json) {
2404
- if (!record(json)) return {};
2405
- const out = {};
2406
- if (typeof json.membershipType === "string") out.membershipType = json.membershipType;
2407
- if (typeof json.isUnlimited === "boolean") out.isUnlimited = json.isUnlimited;
2408
- if (typeof json.billingCycleStart === "string") out.billingCycleStart = json.billingCycleStart;
2409
- if (typeof json.billingCycleEnd === "string") out.billingCycleEnd = json.billingCycleEnd;
2410
- if (json.limitType === "team") out.isTeam = true;
2411
- const individualPlan = record(json.individualUsage?.plan) ? json.individualUsage.plan : undefined;
2412
- const teamPlan = record(json.teamUsage?.plan) ? json.teamUsage.plan : undefined;
2413
- if (individualPlan !== undefined || teamPlan !== undefined) {
2414
- const used = firstFinite(individualPlan?.used, teamPlan?.used);
2415
- const limit = firstFinite(individualPlan?.limit, teamPlan?.limit);
2416
- const totalPercentUsed = firstFinite(individualPlan?.totalPercentUsed, teamPlan?.totalPercentUsed);
2417
- const autoPercentUsed = firstFinite(individualPlan?.autoPercentUsed, teamPlan?.autoPercentUsed);
2418
- let apiPercentUsed = firstFinite(individualPlan?.apiPercentUsed, teamPlan?.apiPercentUsed);
2419
- if (apiPercentUsed === undefined) {
2420
- apiPercentUsed = percentFromUsageMessage(json.namedModelSelectedDisplayMessage);
2421
- }
2422
- if (used !== undefined) out.planUsedCents = used;
2423
- if (limit !== undefined) out.planLimitCents = limit;
2424
- if (totalPercentUsed !== undefined) out.totalPercentUsed = totalPercentUsed;
2425
- if (autoPercentUsed !== undefined) out.autoPercentUsed = autoPercentUsed;
2426
- if (apiPercentUsed !== undefined) out.apiPercentUsed = apiPercentUsed;
2427
- }
2428
- const onDemand = json.individualUsage?.onDemand;
2429
- if (record(onDemand)) {
2430
- const usedDollars = centsToDollars(onDemand.used);
2431
- const limitDollars = centsToDollars(onDemand.limit);
2432
- const remainingDollars = centsToDollars(onDemand.remaining);
2433
- if (usedDollars !== undefined || limitDollars !== undefined) {
2434
- out.individualOnDemand = {
2435
- ...usedDollars === undefined ? {} : { usedDollars },
2436
- ...limitDollars === undefined ? {} : { limitDollars },
2437
- ...remainingDollars === undefined ? {} : { remainingDollars },
2438
- };
2439
- }
2440
- }
2441
- const teamOnDemand = json.teamUsage?.onDemand;
2442
- if (record(teamOnDemand)) {
2443
- const usedDollars = centsToDollars(teamOnDemand.used);
2444
- const limitDollars = centsToDollars(teamOnDemand.limit);
2445
- const remainingDollars = centsToDollars(teamOnDemand.remaining);
2446
- if (usedDollars !== undefined || limitDollars !== undefined) {
2447
- out.teamOnDemand = {
2448
- ...usedDollars === undefined ? {} : { usedDollars },
2449
- ...limitDollars === undefined ? {} : { limitDollars },
2450
- ...remainingDollars === undefined ? {} : { remainingDollars },
2451
- };
2452
- }
2453
- }
2454
- return out;
2455
- }
2456
-
2457
- /**
2458
- * Classify one aggregation row into the included-usage pool whose percentage
2459
- * it draws on. Cursor's own usage table splits rows with the same `tier`
2460
- * field: tier 2 is the Cursor Models (Auto + Composer) pool, and every other
2461
- * row — named models, plus `default` rows at tier 1 that the dashboard labels
2462
- * "auto (overflow)" — is billed to the Other Models (API) pool.
2463
- */
2464
- export function usageModelPool(entry) {
2465
- const intent = typeof entry?.modelIntent === "string" ? entry.modelIntent.trim() : "";
2466
- const tier = asFiniteNumber(entry?.tier);
2467
- if (tier === 2) return "auto";
2468
- if (tier === undefined && intent === "default") return "auto";
2469
- return "api";
2470
- }
2471
-
2472
- /** Token counts one aggregation row reports; a row with none stays unknown. */
2473
- function aggregationTokens(entry) {
2474
- let total;
2475
- for (const field of ["inputTokens", "outputTokens", "cacheWriteTokens", "cacheReadTokens"]) {
2476
- const value = asFiniteNumber(entry?.[field]);
2477
- if (value === undefined) continue;
2478
- total = (total ?? 0) + value;
2479
- }
2480
- return total;
2481
- }
2482
-
2483
- /**
2484
- * Read the aggregation rows into the pool subtotals both projections need.
2485
- * Rows are classified exactly like the dashboard's usage table, and a
2486
- * `default` row at tier 1 is Auto usage that spilled out of the Cursor Models
2487
- * pool and is billed to Other Models.
2488
- */
2489
- function collectModelUsage(json) {
2490
- const rows = [];
2491
- const poolCents = { auto: 0, api: 0 };
2492
- const poolTokens = { auto: 0, api: 0 };
2493
- if (!record(json) || !Array.isArray(json.aggregations)) return { rows, poolCents, poolTokens };
2494
- for (const entry of json.aggregations) {
2495
- if (!record(entry) || typeof entry.modelIntent !== "string") continue;
2496
- const id = entry.modelIntent.trim();
2497
- if (!id) continue;
2498
- const cents = asFiniteNumber(entry.totalCents);
2499
- if (cents === undefined || cents <= 0) continue;
2500
- const pool = usageModelPool(entry);
2501
- const tokens = aggregationTokens(entry);
2502
- rows.push({
2503
- id,
2504
- cents,
2505
- pool,
2506
- overflow: id === "default" && pool === "api" && asFiniteNumber(entry.tier) === 1,
2507
- ...tokens === undefined ? {} : { tokens },
2508
- });
2509
- poolCents[pool] += cents;
2510
- poolTokens[pool] += tokens ?? 0;
2511
- }
2512
- rows.sort((a, b) => b.cents - a.cents);
2513
- return { rows, poolCents, poolTokens };
2514
- }
2515
-
2516
- /**
2517
- * Project one aggregation payload into the per-model rows plus the pool
2518
- * token totals.
2519
- *
2520
- * `pct` is what Cursor's own usage table prints for a model row: the model's
2521
- * share of its pool's spend times that pool's reported percentage, so one
2522
- * Other Models model reads the same percentage as the pool row above it. A
2523
- * model whose pool percentage was not reported carries no `pct` at all.
2524
- */
2525
- function projectModelUsage(json, options = {}) {
2526
- const limit = options.limit ?? MAX_USAGE_MODELS;
2527
- const { rows, poolCents, poolTokens } = collectModelUsage(json);
2528
- const poolPercent = {
2529
- auto: asFiniteNumber(options.autoPercentUsed),
2530
- api: asFiniteNumber(options.apiPercentUsed),
2531
- };
2532
- const cap = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), rows.length) : rows.length;
2533
- const models = [];
2534
- for (let index = 0; index < cap; index++) {
2535
- const row = rows[index];
2536
- const model = {
2537
- id: row.id,
2538
- pool: row.pool,
2539
- spentDollars: Math.round((row.cents / 100) * 100) / 100,
2540
- };
2541
- if (row.tokens !== undefined) model.tokens = row.tokens;
2542
- if (row.overflow === true) model.overflow = true;
2543
- const percent = poolPercent[row.pool];
2544
- if (percent !== undefined && poolCents[row.pool] > 0) {
2545
- model.pct = Math.round((row.cents / poolCents[row.pool]) * percent * 10) / 10;
2546
- }
2547
- models.push(model);
2548
- }
2549
- return { models, pools: { auto: { tokens: poolTokens.auto }, api: { tokens: poolTokens.api } } };
2550
- }
2551
-
2552
- /**
2553
- * Project `/api/dashboard/get-aggregated-usage-events` into a browser-safe
2554
- * per-model list. Cursor's named/API models (Claude, Grok, GPT, …) live here;
2555
- * `/api/usage` only exposes the legacy `gpt-4` quota bucket.
2556
- */
2557
- export function parseModelAggregations(json, options = {}) {
2558
- return projectModelUsage(json, options).models;
2559
- }
2560
-
2561
- /** The full projection the settings panel's included-usage table renders. */
2562
- export function parseModelUsage(json, options = {}) {
2563
- return projectModelUsage(json, options);
2564
- }
2565
-
2566
- /**
2567
- * Pool facts for the panel's included-usage table: Cursor's reported pool
2568
- * percentage, plus the pool's token total when the aggregated read succeeded.
2569
- * A pool with neither is left out, so the panel never prints an empty row.
2570
- */
2571
- export function usagePools(parsed, modelUsage) {
2572
- const pools = {};
2573
- for (const [pool, percent] of [["auto", parsed?.autoPercentUsed], ["api", parsed?.apiPercentUsed]]) {
2574
- const facts = {};
2575
- const pct = asFiniteNumber(percent);
2576
- if (pct !== undefined) facts.pct = pct;
2577
- if (modelUsage !== undefined) facts.tokens = modelUsage.pools[pool].tokens;
2578
- if (Object.keys(facts).length > 0) pools[pool] = facts;
2579
- }
2580
- return pools;
2581
- }
2582
-
2583
- /** Pull `requestQuotaPerSeat` for the active team from `/api/dashboard/teams`. */
2584
- export function parseRequestQuotaPerSeat(json, teamId) {
2585
- if (!record(json) || !Array.isArray(json.teams)) return undefined;
2586
- const match = (teamId ? json.teams.find((t) => String(t?.id) === String(teamId)) : undefined) ?? json.teams[0];
2587
- const quota = match?.requestQuotaPerSeat ?? match?.request_quota_per_seat;
2588
- return typeof quota === "number" && Number.isFinite(quota) ? quota : undefined;
2589
- }
2590
-
2591
- function usageDaysLeft(billingCycleEnd, now) {
2592
- const end = new Date(billingCycleEnd).getTime();
2593
- if (!Number.isFinite(end)) return undefined;
2594
- return Math.max(0, Math.ceil((end - now) / (24 * 60 * 60 * 1000)));
2595
- }
2596
-
2597
- /**
2598
- * Read usage from Cursor's dashboard endpoints using the same session cookie
2599
- * the web dashboard sends. The browser receives only the parsed projection;
2600
- * the access token and cookie are request-local host values. Concurrent polls
2601
- * share one in-flight request; successful reads are cached for a short TTL.
2602
- */
2603
- export class CursorUsageReader {
2604
- constructor(auth, options = {}) {
2605
- this.auth = auth;
2606
- this.fetch = options.fetch ?? nativeFetch;
2607
- this.now = options.now ?? Date.now;
2608
- this.ttlMs = options.ttlMs ?? USAGE_TTL_MS;
2609
- this.logger = options.logger;
2610
- this.#cache = { at: 0, value: undefined };
2611
- }
2612
-
2613
- #cache;
2614
-
2615
- #log(level, message, ...args) {
2616
- try {
2617
- this.logger?.[level]?.(`cursor-subscription: ${message}`, ...args);
2618
- } catch {}
2619
- }
2620
-
2621
- async #request(url, cookie, options = {}) {
2622
- const response = await this.fetch(url, {
2623
- method: options.method ?? "GET",
2624
- redirect: "follow",
2625
- headers: {
2626
- accept: "application/json",
2627
- origin: USAGE_API_ORIGIN,
2628
- referer: "https://cursor.com/dashboard",
2629
- cookie,
2630
- "user-agent": "dsh-cursor-subscription/0.2.0",
2631
- ...(options.body === undefined ? {} : { "content-type": "application/json" }),
2632
- },
2633
- ...options.body === undefined ? {} : { body: JSON.stringify(options.body) },
2634
- signal: options.signal,
2635
- });
2636
- if (!response.ok) throw new Error(`HTTP ${response.status} from ${url}`);
2637
- const text = await response.text();
2638
- try {
2639
- return JSON.parse(text);
2640
- } catch (error) {
2641
- throw new Error(`unreadable JSON from ${url}`, { cause: error });
2642
- }
2643
- }
2644
-
2645
- async #tryRequest(url, cookie, options = {}) {
2646
- try {
2647
- return { ok: true, value: await this.#request(url, cookie, options) };
2648
- } catch (error) {
2649
- this.#log("warn", "usage request failed: %s", error instanceof Error ? error.message : error);
2650
- return { ok: false, error };
2651
- }
2652
- }
2653
-
2654
- #mergeSignals(signal, timeoutMs) {
2655
- const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 && typeof AbortSignal.timeout === "function"
2656
- ? AbortSignal.timeout(timeoutMs)
2657
- : undefined;
2658
- if (signal === undefined) return timeout;
2659
- if (timeout === undefined) return signal;
2660
- return typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal;
2661
- }
2662
-
2663
- async read({ force = false, signal } = {}) {
2664
- const now = this.now();
2665
- if (!force && this.#cache.value !== undefined && now - this.#cache.at < this.ttlMs) {
2666
- return structuredClone(this.#cache.value);
2667
- }
2668
- const credential = await this.auth.credential({ signal });
2669
- const userSub = getTokenSub(credential.access);
2670
- if (userSub === undefined) throw new Error("Cursor session token has no usable user identity");
2671
- const cookie = `WorkosCursorSessionToken=${userSub}::${credential.access}`;
2672
-
2673
- const [usageResult, summaryResult, teamsResult] = await Promise.all([
2674
- this.#tryRequest(`${USAGE_URL}?user=${encodeURIComponent(userSub)}`, cookie, { signal }),
2675
- this.#tryRequest(USAGE_SUMMARY_URL, cookie, { signal }),
2676
- this.#tryRequest(USAGE_TEAMS_URL, cookie, { method: "POST", body: { activeOnly: false }, signal }),
2677
- ]);
2678
- if (!usageResult.ok && !summaryResult.ok) {
2679
- const failed = summaryResult.error ?? usageResult.error;
2680
- throw failed instanceof Error ? failed : new Error("Could not read Cursor usage");
2681
- }
2682
- const usage = usageResult.ok ? usageResult.value : undefined;
2683
- const summary = summaryResult.ok ? summaryResult.value : undefined;
2684
- const teams = teamsResult.ok ? teamsResult.value : undefined;
2685
-
2686
- const legacy = parseLegacyBucket(usage);
2687
- const parsed = parseUsageSummary(summary);
2688
- const requestQuotaPerSeat = parseRequestQuotaPerSeat(teams);
2689
- const includedRequests =
2690
- computeIncludedRequests({
2691
- legacy,
2692
- isTeam: parsed.isTeam === true,
2693
- planUsedCents: parsed.planUsedCents,
2694
- requestQuotaPerSeat,
2695
- }) ?? (legacy !== undefined ? computeIncludedRequests({ legacy, isTeam: false }) : undefined);
2696
-
2697
- const modelUsage = await this.#readModelUsage(cookie, parsed, now, signal);
2698
- const hasPlan =
2699
- parsed.totalPercentUsed !== undefined || parsed.autoPercentUsed !== undefined || parsed.apiPercentUsed !== undefined;
2700
-
2701
- const value = {
2702
- fetchedAt: now,
2703
- membershipType: parsed.membershipType,
2704
- isUnlimited: parsed.isUnlimited,
2705
- isTeam: parsed.isTeam,
2706
- billingCycle: parsed.billingCycleEnd === undefined ? undefined : {
2707
- start: parsed.billingCycleStart,
2708
- end: parsed.billingCycleEnd,
2709
- daysLeft: usageDaysLeft(parsed.billingCycleEnd, now),
2710
- },
2711
- plan: hasPlan ? {
2712
- ...parsed.totalPercentUsed === undefined ? {} : { totalPercentUsed: parsed.totalPercentUsed },
2713
- ...parsed.autoPercentUsed === undefined ? {} : { autoPercentUsed: parsed.autoPercentUsed },
2714
- ...parsed.apiPercentUsed === undefined ? {} : { apiPercentUsed: parsed.apiPercentUsed },
2715
- } : undefined,
2716
- includedRequests,
2717
- individualOnDemand: parsed.individualOnDemand,
2718
- teamOnDemand: parsed.teamOnDemand,
2719
- pools: usagePools(parsed, modelUsage),
2720
- models: modelUsage?.models ?? [],
2721
- };
2722
- this.#cache = { at: now, value };
2723
- this.#log("info", "usage read: %s", JSON.stringify({ membershipType: value.membershipType, includedRequests: value.includedRequests, models: value.models.length, apiPercentUsed: value.plan?.apiPercentUsed }));
2724
- return structuredClone(value);
2725
- }
2726
-
2727
- /**
2728
- * Read the per-model aggregation for the billing window. Returns the model
2729
- * rows plus the pool token totals, or undefined when the read failed — the
2730
- * panel then keeps the pool percentages from `/api/usage-summary` and shows
2731
- * the token cells as unavailable.
2732
- */
2733
- async #readModelUsage(cookie, parsed, now, signal) {
2734
- const start = parsed.billingCycleStart !== undefined ? new Date(parsed.billingCycleStart).getTime() : now - 32 * 24 * 60 * 60 * 1000;
2735
- const end = parsed.billingCycleEnd !== undefined ? new Date(parsed.billingCycleEnd).getTime() : now;
2736
- if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
2737
- try {
2738
- const aggregated = await this.#request(USAGE_AGGREGATED_URL, cookie, {
2739
- method: "POST",
2740
- body: { teamId: 0, startDate: start, endDate: end },
2741
- signal: this.#mergeSignals(signal, 8_000),
2742
- });
2743
- return parseModelUsage(aggregated, {
2744
- autoPercentUsed: parsed.autoPercentUsed,
2745
- apiPercentUsed: parsed.apiPercentUsed,
2746
- });
2747
- } catch (error) {
2748
- this.#log("warn", "aggregated model usage unavailable: %s", error instanceof Error ? error.message : error);
2749
- return undefined;
2750
- }
2751
- }
2752
-
2753
- clear() {
2754
- this.#cache = { at: 0, value: undefined };
2755
- }
2756
- }
2757
- //#endregion
2758
-
2759
- //#region adapter
2760
- export const TOOL_REJECT_REASON =
2761
- "Tool not available in this environment. Use the MCP tools provided instead.";
2762
-
2763
- /** Block indices are fixed per type so the assembler keeps blocks distinct. */
2764
- const TEXT_BLOCK_INDEX = 0;
2765
- const REASONING_BLOCK_INDEX = 1;
2766
- const TOOL_BLOCK_INDEX = 2;
2767
- /**
2768
- * Progress notes ride their own reasoning block, so the model's own thinking
2769
- * stays exactly what the model produced. Reasoning is the only channel an
2770
- * adapter has for showing work that is not answer text, and a Cursor step
2771
- * otherwise reduces to one sentence plus a tool card: the protocol round trips
2772
- * that cost the turn most of its wall clock — a declined native tool call, a
2773
- * tool-schema fetch — are invisible, and a turn of them reads as the same
2774
- * request being submitted again and again.
2775
- */
2776
- export const PROGRESS_BLOCK_INDEX = 3;
2777
-
2778
- /**
2779
- * One reader-visible progress line.
2780
- * @param text What just happened, in one clause.
2781
- * @returns A reasoning delta the conversation view renders as process.
2782
- */
2783
- function cursorProgress(text) {
2784
- return { type: "reasoning-delta", index: PROGRESS_BLOCK_INDEX, text: `[cursor] ${text}\n` };
2785
- }
2786
-
2787
- /** Reader-facing name of one decoded exec. */
2788
- function nativeToolName(exec) {
2789
- if (exec.case === "unknown") return `tool (field ${exec.field ?? "?"})`;
2790
- return exec.case.replace(/ExecArgs$|Args$/, "");
2791
- }
2792
-
2793
- /**
2794
- * DSH `LlmAdapter` for the Cursor subscription. Normal turns use Cursor
2795
- * checkpoints; MCP tool calls keep their HTTP/2 Run alive across DSH steps so
2796
- * the following tool-result can resume the exact pending exec with mcpResult.
2797
- */
2798
- export class CursorAdapter extends LlmAdapter {
2799
- constructor(options) {
2800
- super();
2801
- this.auth = options.auth;
2802
- this.fetchModels = options.fetchModels ?? fetchUsableModels;
2803
- this.idleTimeoutMs = options.idleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS;
2804
- this.progressTimeoutMs = options.progressTimeoutMs ?? STREAM_PROGRESS_TIMEOUT_MS;
2805
- this.idleCheckIntervalMs = options.idleCheckIntervalMs ?? 15000;
2806
- this.modelsCacheTtlMs = options.modelsCacheTtlMs ?? 5 * 60 * 1000;
2807
- this.sessionStateTtlMs = options.sessionStateTtlMs ?? SESSION_STATE_TTL_MS;
2808
- const fallbackSettings = resolveCursorSettings({
2809
- maxToolRounds: options.maxToolRounds,
2810
- retryCount: options.retryCount,
2811
- retryIntervalMs: options.retryIntervalMs,
2812
- retryHttpStatusCodes: options.retryHttpStatusCodes,
2813
- });
2814
- this.settings = options.settings ?? (() => fallbackSettings);
2815
- this.createAgentRun = options.createAgentRun ?? ((access) => new AgentRun(access));
2816
- this.resolveAttachments = options.resolveAttachments;
2817
- this.sleep = options.sleep ?? abortableDelay;
2818
- this.now = options.now ?? Date.now;
2819
- this.logger = options.logger;
2820
- this.hangTracePath = options.hangTracePath ?? join(homedir(), ".dsh", "cursor-hang-trace.log");
2821
- this.toolCallSettleMs = options.toolCallSettleMs ?? TOOL_CALL_SETTLE_MS;
2822
- this.#modelsCache = { at: 0, models: undefined };
2823
- }
2824
-
2825
- #modelsCache;
2826
- /** Persisted conversation checkpoints + referenced blobs per DSH session id. */
2827
- #sessions = new Map();
2828
-
2829
- #evictStaleSessions() {
2830
- const now = this.now();
2831
- for (const [key, state] of this.#sessions) {
2832
- if (now - (state.lastAccessMs ?? now) <= this.sessionStateTtlMs) continue;
2833
- state.bridge?.run.close();
2834
- this.#sessions.delete(key);
2835
- }
2836
- }
2837
-
2838
- async #discoverModels(force = false) {
2839
- const now = this.now();
2840
- if (!force && this.#modelsCache.models !== undefined && now - this.#modelsCache.at < this.modelsCacheTtlMs) {
2841
- return this.#modelsCache.models;
2842
- }
2843
- const access = await this.auth.accessToken();
2844
- const source = await this.fetchModels(access);
2845
- if (source.length === 0) throw new Error("Cursor returned no usable models");
2846
- // Sorted here so the picker and the settings list agree, and so a change
2847
- // in Cursor's response order cannot reshuffle either surface.
2848
- const result = sortModelsByName(
2849
- source.map((model) => ({
2850
- id: model.id,
2851
- name: model.name || model.id,
2852
- inputModalities: ["text", "image"],
2853
- })),
2854
- );
2855
- this.#modelsCache = { at: now, models: result };
2856
- return result;
2857
- }
2858
-
2859
- invalidateModels() {
2860
- this.#modelsCache = { at: 0, models: undefined };
2861
- }
2862
-
2863
- providerInfo(provider) {
2864
- return { id: provider, name: "Cursor subscription" };
2865
- }
2866
-
2867
- async listModels(provider) {
2868
- const models = await this.#discoverModels();
2869
- return models.map((model) => ({ provider, ...model }));
2870
- }
2871
-
2872
- /** RPC-facing model listing; `force` bypasses the discovery cache. */
2873
- async listModelsForRpc({ force = false, signal } = {}) {
2874
- const models = await this.#discoverModels(force);
2875
- if (signal?.aborted) throw new LlmError("Cursor model listing aborted", "ABORTED");
2876
- return models;
2877
- }
2878
-
2879
- async resolveModel(provider, model, _signal) {
2880
- let name = model;
2881
- try {
2882
- const models = await this.#discoverModels();
2883
- const found = models.find((entry) => entry.id === model);
2884
- if (found !== undefined) name = found.name;
2885
- } catch {}
2886
- return {
2887
- provider,
2888
- id: model,
2889
- name,
2890
- inputModalities: ["text", "image"],
2891
- context: { contextWindow: DEFAULT_CONTEXT_WINDOW },
2892
- defaultMaxTokens: DEFAULT_MAX_TOKENS,
2893
- };
2894
- }
2895
-
2896
- async *stream(options) {
2897
- const consumer = new AbortController();
2898
- const upstream = options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
2899
- let run;
2900
- let abortRun;
2901
- let bridgeStored = false;
2902
- // Persisted conversation state per session: the server's checkpoint,
2903
- // referenced blobs, and (while an MCP tool runs) the live HTTP/2 bridge.
2904
- const sessionKey = options.sessionId === undefined ? undefined : String(options.sessionId);
2905
- this.#evictStaleSessions();
2906
- const prior = sessionKey === undefined ? undefined : this.#sessions.get(sessionKey);
2907
- const persisted = {
2908
- checkpoint: prior?.checkpoint === undefined ? undefined : Uint8Array.from(prior.checkpoint),
2909
- blobs: new Map(prior?.blobs ?? []),
2910
- };
2911
- // DSH condenses the history mid-session. A Cursor conversation checkpoint
2912
- // captured before that condensing still carries the pre-compaction
2913
- // transcript, so reusing it hands the model exactly the context DSH just
2914
- // freed: the turn then runs on a stale, oversized conversation and
2915
- // re-plans the same work step after step. Drop the checkpoint and its
2916
- // blobs once per new compaction, and never resume a live bridge across
2917
- // one, so the next run is rebuilt from what DSH kept.
2918
- const compactionId = compactionIdOf(options);
2919
- const compacted = compactionId !== undefined && compactionId !== prior?.compactionId;
2920
- if (compacted) {
2921
- persisted.checkpoint = undefined;
2922
- persisted.blobs.clear();
2923
- }
2924
- // The opt-in rebuild: drop the server-side conversation as well, so the run
2925
- // below is assembled from the DSH history alone.
2926
- const replayHistoryEachStep = readConfigField(this.settings().replayHistoryEachStep) === true;
2927
- if (replayHistoryEachStep) {
2928
- persisted.checkpoint = undefined;
2929
- persisted.blobs.clear();
2930
- }
2931
- persisted.compactionId = compactionId ?? prior?.compactionId;
2932
- try {
2933
- if (options.stop !== undefined) throw new LlmError("cursor-subscription does not support GenerateOptions.stop", "UNSUPPORTED_OPTION");
2934
- const requestSettings = resolveCursorSettings(this.settings());
2935
- const mcpTools = (options.tools ?? []).map((tool) =>
2936
- encodeMcpToolDefinition({
2937
- name: tool.name,
2938
- description: tool.description,
2939
- inputSchema: encodeValue(tool.parameters ?? {}),
2940
- }),
2941
- );
2942
- const toolResults = collectToolResults(options);
2943
- const liveBridge = prior?.bridge;
2944
- const canResume = !replayHistoryEachStep && !compacted && liveBridge !== undefined && !liveBridge.run.finished && !liveBridge.run.stream?.destroyed && toolResults.length > 0;
2945
- let toolRoundCount = canResume ? (liveBridge.toolRoundCount ?? 0) : 0;
2946
- let blobStore;
2947
- if (canResume) {
2948
- run = liveBridge.run;
2949
- // A previous step paused this reader to stop waiting for frames it
2950
- // no longer needed; continue the same Run and read again.
2951
- run.frames?.resume?.();
2952
- blobStore = persisted.blobs;
2953
- abortRun = () => run.abort(new Error("Cursor request aborted by caller"));
2954
- upstream.addEventListener("abort", abortRun, { once: true });
2955
- if (upstream.aborted) abortRun();
2956
- for (const pending of liveBridge.pendingExecs) {
2957
- const result = toolResults.find((entry) => entry.toolCallId === pending.toolCallId);
2958
- if (result?.sandboxDenied === true) {
2959
- yield cursorProgress("the sandbox refused a tool call; told the model how to ask for approval");
2960
- }
2961
- const payload = result === undefined
2962
- ? encodeMcpError("Tool result not provided")
2963
- : encodeMcpResult(result);
2964
- if (!run.writeMessage(encodeExecClientMessageEnvelope(encodeExecClientMessage(pending.id, pending.execId, 11, payload)))) {
2965
- throw new Error("Cursor tool continuation bridge closed before accepting the result");
2966
- }
2967
- }
2968
- } else {
2969
- if (liveBridge !== undefined) {
2970
- liveBridge.run.close();
2971
- // A checkpoint paused on an MCP call cannot safely accept a normal
2972
- // user action. Fall back to textual cold-start reconstruction.
2973
- persisted.checkpoint = undefined;
2974
- persisted.blobs.clear();
2975
- }
2976
- const access = await this.auth.accessToken({ signal: upstream });
2977
- const images = await prepareCursorImages(options, this.resolveAttachments?.());
2978
- const built = buildRunPayload(options, options.model, persisted, images);
2979
- blobStore = built.blobStore;
2980
- let retriesUsed = 0;
2981
- for (;;) {
2982
- run = this.createAgentRun(access);
2983
- await run.start();
2984
- abortRun = () => run.abort(new Error("Cursor request aborted by caller"));
2985
- upstream.addEventListener("abort", abortRun, { once: true });
2986
- if (upstream.aborted) abortRun();
2987
- if (!run.writeMessage(built.payload)) throw new Error("Cursor agent bridge closed before accepting the request");
2988
- const status = await run.waitForResponse(this.idleTimeoutMs);
2989
- if (isSuccessfulAgentResponse(status, run.responseContentType)) {
2990
- run.startHeartbeat();
2991
- break;
2992
- }
2993
- upstream.removeEventListener("abort", abortRun);
2994
- abortRun = undefined;
2995
- run.close();
2996
- if (status === 200) {
2997
- throw new LlmError(
2998
- `Cursor agent returned an unexpected content type: ${run.responseContentType ?? "missing"}`,
2999
- "CURSOR_PROTOCOL",
3000
- );
3001
- }
3002
- if (!shouldRetryHttpStatus(status, retriesUsed, requestSettings)) {
3003
- const message = `Cursor agent returned HTTP ${status}`;
3004
- throw new LlmError(message, classifyCursorError(message));
3005
- }
3006
- retriesUsed++;
3007
- await this.sleep(requestSettings.retryIntervalMs, upstream);
3008
- }
3009
- }
3010
-
3011
- const started = this.now();
3012
- let lastActivity = started;
3013
- let lastProgress = started;
3014
- let emittedToolCall = false;
3015
- let toolCallBuffer = undefined;
3016
- let textOutput = "";
3017
- let outputTokens = 0;
3018
- let streamClosed = false;
3019
- let terminalErrorEmitted = false;
3020
- let toolCallPending = false;
3021
- let toolRoundCounted = false;
3022
- let unknownExecWarned = false;
3023
- const pendingExecs = [];
3024
- let toolCallSettle;
3025
- /**
3026
- * Stop blocking for more frames without ending the reader, so the same
3027
- * Run can be continued by the next DSH step.
3028
- */
3029
- const stopReading = () => {
3030
- if (typeof run.frames?.pause === "function") run.frames.pause();
3031
- else if (typeof run.frames?.finish === "function") run.frames.finish();
3032
- };
3033
- /**
3034
- * Cap how long the loop waits for the frame that closes a tool-call
3035
- * burst. Cursor usually follows mcpArgs with blob writes and a
3036
- * checkpoint, but it may instead send the checkpoint *before* the burst
3037
- * and then nothing at all; waiting for a second checkpoint in that case
3038
- * hung the step until the progress watchdog aborted it.
3039
- */
3040
- const armToolCallSettle = () => {
3041
- toolCallSettle ??= setTimeout(stopReading, this.toolCallSettleMs);
3042
- toolCallSettle.unref?.();
3043
- };
3044
- // Compact trace of the frames this run received, kept for the stall
3045
- // report so a silent server can be diagnosed from the log alone.
3046
- const frameTrace = [];
3047
- const traceFrame = (label) => {
3048
- const last = frameTrace.at(-1);
3049
- if (label === "heartbeat" && last?.label === "heartbeat") {
3050
- last.count++;
3051
- last.at = this.now();
3052
- return;
3053
- }
3054
- frameTrace.push({ label, at: this.now(), count: 1 });
3055
- if (frameTrace.length > 120) frameTrace.shift();
3056
- };
3057
- const stallReport = (message) => {
3058
- const lines = [
3059
- `=== ${new Date().toISOString()} ${message} ===`,
3060
- `session=${sessionKey ?? "-"} model=${options.model ?? "-"} textChars=${textOutput.length}`
3061
- + ` toolCallPending=${toolCallPending} toolRounds=${toolRoundCount}`
3062
- + ` checkpoint=${persisted.checkpoint?.length ?? 0}B blobs=${blobStore?.size ?? 0}`,
3063
- `lastFrame=${Math.round((this.now() - lastActivity) / 1000)}s ago`
3064
- + ` lastContent=${Math.round((this.now() - lastProgress) / 1000)}s ago`,
3065
- ...frameTrace.map((entry) => ` ${new Date(entry.at).toISOString()} ${entry.label}${entry.count > 1 ? ` x${entry.count}` : ""}`),
3066
- "",
3067
- ];
3068
- try {
3069
- appendFileSync(this.hangTracePath, lines.join("\n"));
3070
- } catch {}
3071
- this.logger?.warn?.(`cursor-subscription: ${message}; trace in ${this.hangTracePath}`);
3072
- };
3073
- const handleStall = (message) => {
3074
- stallReport(message);
3075
- // The step already delivered something the user can see — answer
3076
- // text or a tool call — and Cursor stopped sending content without
3077
- // ending the turn. Stop reading instead of raising a retryable
3078
- // error: DSH would discard the delivered answer or tool call and
3079
- // re-run the whole step.
3080
- if ((textOutput.length > 0 || toolCallPending) && typeof run.frames?.pause === "function") {
3081
- stopReading();
3082
- return;
3083
- }
3084
- run.abort(new Error(message));
3085
- };
3086
- const idleCheck = setInterval(() => {
3087
- const now = this.now();
3088
- if (now - lastActivity > this.idleTimeoutMs) {
3089
- handleStall("Cursor stream idle timeout");
3090
- } else if (now - lastProgress > this.progressTimeoutMs) {
3091
- handleStall(`Cursor stream progress timeout: no content for ${this.progressTimeoutMs}ms`);
3092
- }
3093
- }, this.idleCheckIntervalMs);
3094
- idleCheck.unref?.();
3095
-
3096
- try {
3097
- for (;;) {
3098
- const frame = await run.frames.next();
3099
- if (frame === undefined) break;
3100
- lastActivity = this.now();
3101
- traceFrame(describeServerFrame(frame));
3102
- if ((frame.flags & CONNECT_END_STREAM_FLAG) !== 0) {
3103
- const end = parseEndStream(frame.payload);
3104
- if (end !== undefined) {
3105
- terminalErrorEmitted = true;
3106
- yield {
3107
- type: "finish",
3108
- reason: {
3109
- kind: "error",
3110
- failure: {
3111
- message: end.message,
3112
- code: classifyCursorError(`${end.code} ${end.debugCode ?? ""} ${end.message}`),
3113
- },
3114
- },
3115
- };
3116
- streamClosed = true;
3117
- break;
3118
- }
3119
- break;
3120
- }
3121
- const message = decodeAgentServerMessage(frame.payload);
3122
- // Heartbeats keep the connection alive but are not content
3123
- // progress; a server that only pings us (e.g. after an empty
3124
- // partialToolCall placeholder) must still hit the stall timeout
3125
- // instead of leaving the UI spinning forever.
3126
- if (!(message.case === "interactionUpdate" && message.value?.type === "heartbeat")) {
3127
- lastProgress = this.now();
3128
- }
3129
- if (message.case === "conversationCheckpointUpdate") {
3130
- // Reader.bytes() returns a view into the frame buffer; copy it before
3131
- // retaining it for a later request.
3132
- persisted.checkpoint = Uint8Array.from(message.value);
3133
- if (toolCallPending) {
3134
- // A checkpoint after mcpArgs closes the tool-call step. Keep the
3135
- // same Run alive; the next DSH step resumes it with McpResult
3136
- // field 11. Cursor does not always send this checkpoint after
3137
- // the burst — the traced hangs have it *before* mcpArgs — so the
3138
- // wait for it is capped by the settle timer armed below.
3139
- streamClosed = true;
3140
- break;
3141
- }
3142
- } else if (message.case === "interactionUpdate") {
3143
- const update = message.value;
3144
- if (update.type === "textDelta") {
3145
- if (update.text.length > 0) {
3146
- textOutput += update.text;
3147
- yield { type: "text-delta", index: TEXT_BLOCK_INDEX, text: update.text };
3148
- }
3149
- } else if (update.type === "thinkingDelta") {
3150
- if (update.text.length > 0) {
3151
- yield { type: "reasoning-delta", index: REASONING_BLOCK_INDEX, text: update.text };
3152
- }
3153
- } else if (update.type === "thinkingCompleted") {
3154
- if (update.durationMs > 0) {
3155
- yield cursorProgress(`thinking finished after ${(update.durationMs / 1000).toFixed(1)}s`);
3156
- }
3157
- } else if (update.type === "tokenDelta") {
3158
- // Each frame carries the tokens that delta produced, not a
3159
- // running total, so the step's output count is their sum;
3160
- // keeping the last frame reported "1 token" for a paragraph.
3161
- if (Number.isFinite(update.tokens) && update.tokens > 0) outputTokens += update.tokens;
3162
- } else if (update.type === "partialToolCall") {
3163
- // Buffer only. Emitting incomplete tool-call chunks here created
3164
- // nameless DSH tool blocks that competed with real mcpArgs frames.
3165
- if (toolCallBuffer === undefined || toolCallBuffer.id !== update.callId) {
3166
- toolCallBuffer = { id: update.callId, name: "", arguments: "" };
3167
- }
3168
- if (update.argsTextDelta.length > 0) {
3169
- toolCallBuffer.arguments += update.argsTextDelta;
3170
- }
3171
- } else if (update.type === "toolCallStarted" || update.type === "toolCallCompleted" || update.type === "toolCallDelta") {
3172
- emittedToolCall = true;
3173
- } else if (update.type === "turnEnded") {
3174
- // Cursor ends every assistant turn with this update, but it does
3175
- // not reliably close the HTTP/2 stream afterwards: the server can
3176
- // keep the run alive with heartbeats only. Ignoring the update
3177
- // therefore left a finished answer waiting until the progress
3178
- // watchdog aborted the run, and the resulting retryable TIMEOUT
3179
- // made DSH discard the answer the user had already read and
3180
- // re-run the whole step. The trailing conversation checkpoint
3181
- // always arrives before turnEnded, so stopping here keeps
3182
- // everything a later run needs. A pending MCP tool call still
3183
- // waits for its checkpoint so the live bridge stays resumable.
3184
- if (!toolCallPending) break;
3185
- }
3186
- } else if (message.case === "kvServerMessage") {
3187
- const kv = message.value;
3188
- if (kv.case === "getBlobArgs") {
3189
- const key = Buffer.from(kv.blobId).toString("hex");
3190
- const blob = blobStore.get(key);
3191
- run.writeMessage(encodeKvClientMessage(encodeGetBlobResult(kv.id, blob)));
3192
- } else if (kv.case === "setBlobArgs") {
3193
- if (kv.blobId !== undefined && kv.blobData !== undefined) {
3194
- const key = Buffer.from(kv.blobId).toString("hex");
3195
- // Reader.bytes() returns views; retain independent copies for both
3196
- // this run's GET handshake and the next run's checkpoint.
3197
- const data = Uint8Array.from(kv.blobData);
3198
- blobStore.set(key, data);
3199
- persisted.blobs.set(key, data);
3200
- }
3201
- // Acknowledge server blob writes; without the reply the
3202
- // run can stall waiting for the handshake to finish.
3203
- run.writeMessage(encodeKvClientMessage(encodeSetBlobResult(kv.id)));
3204
- }
3205
- } else if (message.case === "execServerMessage") {
3206
- const exec = message.value;
3207
- if (exec.case === "requestContextArgs") {
3208
- run.writeMessage(encodeExecClientMessageEnvelope(encodeExecClientMessage(exec.id, exec.execId, 10, encodeRequestContextResult(mcpTools))));
3209
- yield cursorProgress(`loaded the DSH tool schemas (${mcpTools.length} tools)`);
3210
- } else if (exec.case === "mcpArgs") {
3211
- if (!toolRoundCounted) {
3212
- toolRoundCount++;
3213
- toolRoundCounted = true;
3214
- }
3215
- if (toolRoundCount > requestSettings.maxToolRounds) {
3216
- throw new LlmError(
3217
- `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.`,
3218
- "TOOL_LIMIT",
3219
- );
3220
- }
3221
- // decodeExecServerMessage nests the parsed McpArgs under
3222
- // `exec.args`: { name, toolCallId, providerIdentifier, toolName, args }.
3223
- const mcp = exec.args ?? {};
3224
- const args = {};
3225
- for (const [key, value] of Object.entries(mcp.args ?? {})) {
3226
- try {
3227
- args[key] = decodeValue(value);
3228
- } catch {
3229
- args[key] = null;
3230
- }
3231
- }
3232
- const candidateName = mcp.toolName || mcp.name;
3233
- const tool = resolveTextTool(candidateName, options.tools ?? []);
3234
- if (tool === undefined) {
3235
- // Cursor occasionally emits empty/placeholder MCP execs around
3236
- // real calls. Reject them immediately so they never become
3237
- // pending bridge entries that later poison the resume path with
3238
- // "Tool result not provided".
3239
- run.writeMessage(encodeExecClientMessageEnvelope(encodeExecClientMessage(
3240
- exec.id,
3241
- exec.execId,
3242
- 11,
3243
- encodeMcpError(`Unknown or unsupported MCP tool: ${candidateName || "(empty)"}`),
3244
- )));
3245
- continue;
3246
- }
3247
- emittedToolCall = true;
3248
- const id = mcp.toolCallId || exec.execId || crypto.randomUUID();
3249
- const name = tool.name;
3250
- const normalizedArgs = normalizeTextToolArguments(tool, args);
3251
- const argumentsJson = JSON.stringify(normalizedArgs);
3252
- // Parallel MCP calls must use distinct DSH block indexes. Reusing
3253
- // TOOL_BLOCK_INDEX causes the agent loop to keep only one call while
3254
- // pendingExecs still waits for every sibling.
3255
- const blockIndex = TOOL_BLOCK_INDEX + pendingExecs.length;
3256
- yield { type: "block-start", index: blockIndex, blockType: "tool-call" };
3257
- yield {
3258
- type: "tool-call-delta",
3259
- index: blockIndex,
3260
- id: ToolCallId(id),
3261
- name,
3262
- argumentsDelta: argumentsJson,
3263
- };
3264
- yield {
3265
- type: "block-end",
3266
- index: blockIndex,
3267
- block: {
3268
- type: "tool-call",
3269
- id: ToolCallId(id),
3270
- name,
3271
- arguments: argumentsJson,
3272
- },
3273
- };
3274
- pendingExecs.push({ id: exec.id, execId: exec.execId, toolCallId: id });
3275
- toolCallPending = true;
3276
- yield cursorProgress(`calling the DSH tool ${name}`);
3277
- // Do not close the Run. Cursor usually sends blob writes + a
3278
- // checkpoint right after mcpArgs, which this loop still wants to
3279
- // capture for the persisted session, but the ordering is not
3280
- // guaranteed: traced hangs end with `checkpoint | mcpArgs` and no
3281
- // further frame, which left the step waiting until the progress
3282
- // watchdog aborted it and DSH re-ran the whole step instead of
3283
- // running the tool. Cap the wait; sibling calls of one burst
3284
- // arrive within ~100 ms of each other.
3285
- streamClosed = true;
3286
- armToolCallSettle();
3287
- } else {
3288
- const reply = rejectionFor(exec);
3289
- if (reply !== undefined) {
3290
- if (exec.case === "unknown" && !unknownExecWarned) {
3291
- unknownExecWarned = true;
3292
- this.logger?.warn?.(
3293
- `cursor-subscription: Cursor requested an exec this build does not know (field ${exec.field}); rejected it so the run can continue with MCP tools`,
3294
- );
3295
- }
3296
- run.writeMessage(encodeExecClientMessageEnvelope(encodeExecClientMessage(exec.id, exec.execId, reply.field, reply.payload)));
3297
- yield cursorProgress(`native ${nativeToolName(exec)} call declined; using the DSH tools instead`);
3298
- } else if (exec.case === "unknown") {
3299
- this.logger?.warn?.("cursor-subscription: Cursor requested an exec with no addressable field; the run may stall");
3300
- }
3301
- }
3302
- }
3303
- }
3304
- } finally {
3305
- clearInterval(idleCheck);
3306
- clearTimeout(toolCallSettle);
3307
- }
3308
-
3309
- if (!terminalErrorEmitted && !toolCallPending) {
3310
- const textToolCalls = parseTextToolCalls(textOutput, options.tools ?? []);
3311
- for (let index = 0; index < textToolCalls.length; index++) {
3312
- const call = textToolCalls[index];
3313
- const blockIndex = TOOL_BLOCK_INDEX + index;
3314
- yield { type: "block-start", index: blockIndex, blockType: "tool-call" };
3315
- yield {
3316
- type: "tool-call-delta",
3317
- index: blockIndex,
3318
- id: ToolCallId(call.id),
3319
- name: call.name,
3320
- argumentsDelta: call.arguments,
3321
- };
3322
- yield {
3323
- type: "block-end",
3324
- index: blockIndex,
3325
- block: { type: "tool-call", id: ToolCallId(call.id), name: call.name, arguments: call.arguments },
3326
- };
3327
- }
3328
- if (textToolCalls.length > 0) {
3329
- emittedToolCall = true;
3330
- streamClosed = true;
3331
- }
3332
- }
3333
-
3334
- const liveToolBridge = toolCallPending && pendingExecs.length > 0 && !run.finished;
3335
- if (sessionKey !== undefined && (persisted.checkpoint !== undefined || liveToolBridge)) {
3336
- persisted.lastAccessMs = this.now();
3337
- if (liveToolBridge) {
3338
- persisted.bridge = { run, pendingExecs, toolRoundCount };
3339
- bridgeStored = true;
3340
- }
3341
- // Refresh insertion order for simple LRU-style eviction. A checkpoint
3342
- // may reference every retained blob, so prune whole sessions rather than
3343
- // individual blobs. Close an evicted live bridge to avoid socket leaks.
3344
- this.#sessions.delete(sessionKey);
3345
- this.#sessions.set(sessionKey, persisted);
3346
- while (this.#sessions.size > 100) {
3347
- const oldest = this.#sessions.keys().next().value;
3348
- if (oldest === undefined) break;
3349
- this.#sessions.get(oldest)?.bridge?.run.close();
3350
- this.#sessions.delete(oldest);
3351
- }
3352
- }
3353
-
3354
- if (!terminalErrorEmitted) {
3355
- yield {
3356
- type: "usage",
3357
- usage: {
3358
- // Cursor reports the conversation's prompt size in the
3359
- // checkpoint it last sent; zero only while none has arrived.
3360
- inputTokens: decodeCheckpointUsedTokens(persisted.checkpoint) ?? 0,
3361
- outputTokens,
3362
- },
3363
- };
3364
- if (streamClosed && emittedToolCall) {
3365
- yield { type: "finish", reason: { kind: "tool-calls" } };
3366
- } else if (run.trailers && Number(run.trailers["grpc-status"]) > 0) {
3367
- const message = run.trailers["grpc-message"] ?? "Cursor agent run failed";
3368
- yield { type: "finish", reason: { kind: "error", failure: { message, code: classifyCursorError(message) } } };
3369
- } else {
3370
- yield { type: "finish", reason: { kind: "stop" } };
3371
- }
3372
- }
3373
- } catch (error) {
3374
- if (upstream.aborted) {
3375
- yield {
3376
- type: "finish",
3377
- reason: { kind: "aborted", failure: { message: "Cursor request aborted by caller", code: "ABORTED" } },
3378
- };
3379
- } else if (error instanceof LlmError) {
3380
- yield { type: "finish", reason: { kind: "error", failure: { message: error.message, code: error.code } } };
3381
- } else {
3382
- const message = error instanceof Error ? error.message : String(error);
3383
- yield { type: "finish", reason: { kind: "error", failure: { message, code: classifyCursorError(message) } } };
3384
- }
3385
- } finally {
3386
- if (abortRun !== undefined) upstream.removeEventListener("abort", abortRun);
3387
- consumer.abort("cursor stream consumer stopped");
3388
- if (!bridgeStored) run?.close();
3389
- }
3390
- }
3391
- }
3392
-
3393
- /**
3394
- * Parse a Connect end-stream frame into a structured error. Cursor's agent
3395
- * errors carry a `debug` payload with a stable code and human-readable
3396
- * `title`/`detail`; extracting those gives the user the real reason instead of
3397
- * a bare `resource_exhausted`.
3398
- * @returns {undefined | {code: string, debugCode?: string, message: string}}
3399
- */
3400
- export function parseEndStream(payload) {
3401
- try {
3402
- const json = JSON.parse(new TextDecoder().decode(payload));
3403
- const error = json?.error;
3404
- if (!error) return undefined;
3405
- const code = typeof error.code === "string" ? error.code : "unknown";
3406
- let debugCode;
3407
- let title;
3408
- let detail;
3409
- if (Array.isArray(error.details)) {
3410
- for (const entry of error.details) {
3411
- if (!record(entry) || !record(entry.debug)) continue;
3412
- if (typeof entry.debug.error === "string") debugCode = entry.debug.error;
3413
- const details = record(entry.debug.details) ? entry.debug.details : undefined;
3414
- if (details !== undefined) {
3415
- if (typeof details.title === "string") title = details.title;
3416
- if (typeof details.detail === "string") detail = details.detail;
3417
- }
3418
- }
3419
- }
3420
- const fallback = typeof error.message === "string" && error.message.length > 0 ? error.message : undefined;
3421
- const message = [title, detail].filter((part) => typeof part === "string" && part.length > 0).join(" ")
3422
- || (debugCode !== undefined ? `Cursor: ${debugCode}` : undefined)
3423
- || (fallback !== undefined ? `Cursor agent error ${code}: ${fallback}` : `Cursor agent error ${code}`);
3424
- return { code, debugCode, message };
3425
- } catch {
3426
- return undefined;
3427
- }
3428
- }
3429
-
3430
- export function classifyCursorError(message) {
3431
- if (/\b(?:401|403)\b|unauth|invalid.*(?:key|token|credential)/i.test(message)) return "AUTH";
3432
- if (/rate.?limit|\b429\b|quota|resource_exhausted|spend.?limit|usage limit|exceeded/i.test(message)) return "RATE_LIMIT";
3433
- if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST";
3434
- if (/\b408\b|timeout|timed out/i.test(message)) return "TIMEOUT";
3435
- if (/\b5\d\d\b|internal/i.test(message)) return "SERVER";
3436
- if (/\b(?:network|connection|socket|fetch|ECONN|http2)/i.test(message)) return "TRANSPORT";
3437
- return "CURSOR_ERROR";
3438
- }
3439
-
3440
- /**
3441
- * Build the exec-client reply for a native Cursor tool we reject.
3442
- *
3443
- * `ExecClientMessage` numbers its result variants exactly like
3444
- * `ExecServerMessage` numbers its exec variants, which is what lets an exec
3445
- * this build cannot name still be answered: reply on the exec's own field.
3446
- * Every unanswered exec leaves the server waiting for its result, and that
3447
- * wait is what the user sees as a turn that ends right after the model says
3448
- * what it is about to do — the progress watchdog aborts the step and only the
3449
- * preamble text survives.
3450
- */
3451
- export function rejectionFor(exec) {
3452
- switch (exec.case) {
3453
- case "readArgs":
3454
- return { field: 7, payload: encodeReadRejected(exec.path, TOOL_REJECT_REASON) };
3455
- case "lsArgs":
3456
- return { field: 8, payload: encodeLsRejected(exec.path, TOOL_REJECT_REASON) };
3457
- case "grepArgs":
3458
- return { field: 5, payload: encodeGrepError(TOOL_REJECT_REASON) };
3459
- case "writeArgs":
3460
- return { field: 3, payload: encodeWriteRejected(exec.path, TOOL_REJECT_REASON) };
3461
- case "deleteArgs":
3462
- return { field: 4, payload: encodeDeleteRejected(exec.path, TOOL_REJECT_REASON) };
3463
- case "shellArgs":
3464
- // Non-streaming shell exec replies with ShellResult (field 2).
3465
- return { field: 2, payload: encodeShellRejectedResult(undefined, undefined, TOOL_REJECT_REASON) };
3466
- case "shellStreamArgs":
3467
- // Streaming shell exec replies with ShellStream (field 14); a
3468
- // ShellResult here leaves the server waiting for the stream.
3469
- return { field: 14, payload: encodeShellStreamRejected(undefined, undefined, TOOL_REJECT_REASON) };
3470
- case "backgroundShellSpawnArgs":
3471
- return { field: 16, payload: encodeBackgroundShellRejectedResult(undefined, undefined, TOOL_REJECT_REASON) };
3472
- case "fetchArgs":
3473
- return { field: 20, payload: encodeFetchError(exec.url, TOOL_REJECT_REASON) };
3474
- case "writeShellStdinArgs":
3475
- return { field: 23, payload: encodeWriteShellStdinError(TOOL_REJECT_REASON) };
3476
- case "diagnosticsArgs":
3477
- return { field: 9, payload: encodeDiagnosticsResult() };
3478
- case "recordScreenArgs":
3479
- return { field: 21, payload: encodeMcpError("Screen recording is not available") };
3480
- case "computerUseArgs":
3481
- return { field: 22, payload: encodeMcpError("Computer use is not available") };
3482
- case "listMcpResourcesExecArgs":
3483
- return { field: 17, payload: encodeMcpError("MCP resources are not available") };
3484
- case "readMcpResourceExecArgs":
3485
- return { field: 18, payload: encodeMcpError("MCP resources are not available") };
3486
- case "unknown": {
3487
- // Cursor adds exec variants without notice (field 36 appeared this
3488
- // cycle). Answer on the exec's own field with the generic error
3489
- // shape, so the model learns the tool is unavailable and falls back
3490
- // to the MCP tools; live probing shows every coherent reply resumes
3491
- // the run while silence stalls it.
3492
- if (typeof exec.field !== "number") return undefined;
3493
- return { field: exec.field, payload: encodeMcpError(TOOL_REJECT_REASON) };
3494
- }
3495
- default:
3496
- return undefined;
3497
- }
3498
- }
3499
- //#endregion
3500
-
3501
- //#region apply
3502
- export function apply(ctx, config = {}) {
3503
- // DSH 0.1.7 composes a plugin's settings section from its own Config, so
3504
- // there is no section to register: the entry id is the namespace, the
3505
- // `.volatile()` fields above are the editable surface, and `config` holds
3506
- // live references rather than a snapshot. All that is left to state is the
3507
- // page policy — this plugin ships its own Settings → Cursor panel, so the
3508
- // generated form stays off.
3509
- //
3510
- // `configure` replaced `installSection`, which took a namespace, a schema,
3511
- // and a `setSource` callback to track edits. A DSH build without it (the
3512
- // 0.1.5 stable line) keeps the Cursor provider and loses only the settings
3513
- // page, rather than failing the whole entry at boot the way an unguarded
3514
- // call to a removed method does.
3515
- ctx.inject(["settings"], (settingsCtx) => {
3516
- settingsCtx.effect(() => {
3517
- const settings = settingsCtx.settings;
3518
- if (typeof settings.configure !== "function") return undefined;
3519
- return settings.configure({ auto: false }, ctx.fiber);
3520
- });
3521
- });
3522
- const readSettings = () => resolveCursorSettings(config);
3523
- const readSettingsView = () => {
3524
- const value = readSettings();
3525
- const descriptor = ctx.get("settings")?.describe({ redactSecrets: true })
3526
- .find((entry) => entry.ns === SETTINGS_NAMESPACE);
3527
- return { ...value, revision: descriptor?.revision ?? 0 };
3528
- };
3529
- const settingsController = {
3530
- read: readSettingsView,
3531
- update: async (patch, expectedRevision) => {
3532
- resolveCursorSettings({ ...readSettings(), ...patch });
3533
- const service = ctx.get("settings");
3534
- if (service === undefined) throw new Error("Cursor settings storage is unavailable");
3535
- await service.update(SETTINGS_NAMESPACE, patch, expectedRevision);
3536
- return readSettingsView();
3537
- },
3538
- };
3539
- const store = new CursorCredentialStore(ctx.credentials, CREDENTIAL_REF);
3540
- const auth = new CursorAuthService(store, { logger: ctx.logger });
3541
- const adapter = new CursorAdapter({ auth, settings: readSettings, resolveAttachments: () => ctx.get("attachments"), logger: ctx.logger });
3542
- ctx.llm.registerAdapter([PROVIDER], adapter);
3543
- const usageReader = new CursorUsageReader(auth, { logger: ctx.logger });
3544
-
3545
- const coordinator = new CursorLoginCoordinator(auth, { logger: ctx.logger });
3546
- const handler = createCursorRpcHandler(coordinator, {
3547
- openExternal: openCursorAuthUrl,
3548
- usageReader,
3549
- modelsProvider: adapter,
3550
- settings: settingsController,
3551
- });
3552
- // Mount the loopback account channel through Connection. `rpc.handle`
3553
- // registers the channel on the Context that reads the Connection service,
3554
- // but resolves that Context's `webServer` through the providing plugin's own
3555
- // scope, so it only works while the Connection plugin itself declares
3556
- // `webServer`. @deepseek-ai/dsh-client-connection 0.1.5-rc.1 declares
3557
- // `credentials` alone and mounts `/api` from a nested `webServer` Context, so
3558
- // `rpc.handle` fails the entire plugin tree with
3559
- // `cannot get property "webServer" without inject`. Calling the same registry
3560
- // method Connection uses internally with an explicit owner Context that
3561
- // declares `webServer` reuses Connection's envelope, trust fence, and browser
3562
- // authentication, and keeps the route on this plugin's fiber. Fall back to
3563
- // the public `rpc.handle` if that registry method ever disappears.
3564
- ctx.inject(["connection", "webServer"], (webCtx) => {
3565
- const connection = webCtx.connection;
3566
- webCtx.effect(() => {
3567
- if (typeof connection.register === "function") {
3568
- return connection.register(webCtx, CHANNEL, handler, LOOPBACK_RPC);
3569
- }
3570
- return connection.rpc.handle(CHANNEL, handler, LOOPBACK_RPC);
3571
- }, "cursor-subscription: loopback account RPC");
3572
- });
3573
- }
3574
- //#endregion