dsh-subscriptions 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -19
- package/lib/auth/rpc.d.ts +2 -20
- package/lib/auth/rpc.js +3 -49
- package/lib/client/ProviderAccountManager.d.ts +1 -1
- package/lib/client/ProviderAccountManager.js +86 -110
- package/lib/client/ProviderModelEditor.d.ts +1 -1
- package/lib/client/ProviderModelEditor.js +2 -6
- package/lib/client/SubscriptionUsageBadge.d.ts +1 -1
- package/lib/client/SubscriptionUsageBadge.js +1 -13
- package/lib/client/SubscriptionsSection.js +1 -2
- package/lib/client/locales.d.ts +0 -32
- package/lib/client/locales.js +0 -32
- package/lib/client/model-filter.js +1 -1
- package/lib/client/subscription-card-styles.d.ts +1 -1
- package/lib/client/subscription-card-styles.js +1 -1
- package/lib/client.js +175 -489
- package/lib/client.js.map +1 -1
- package/lib/index.js +165 -3275
- package/lib/providers/ollama-settings.d.ts +1 -1
- package/lib/providers/ollama-settings.js +1 -1
- package/package.json +1 -7
- package/lib/client/CursorCard.d.ts +0 -9
- package/lib/client/CursorCard.js +0 -151
- package/lib/providers/cursor-adapter.d.ts +0 -50
- package/lib/providers/cursor-adapter.js +0 -304
- package/lib/providers/cursor-auth.d.ts +0 -43
- package/lib/providers/cursor-auth.js +0 -221
- package/lib/providers/cursor-model-settings.d.ts +0 -15
- package/lib/providers/cursor-model-settings.js +0 -60
- package/lib/providers/cursor-usage.d.ts +0 -9
- package/lib/providers/cursor-usage.js +0 -131
- package/vendor/cursor/LICENSE +0 -21
- package/vendor/cursor/index.d.ts +0 -18
- package/vendor/cursor/index.js +0 -3574
- package/vendor/cursor/proto.js +0 -254
package/vendor/cursor/index.js
DELETED
|
@@ -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(/"/gi, '"')
|
|
2025
|
-
.replace(/'/gi, "'")
|
|
2026
|
-
.replace(/</gi, "<")
|
|
2027
|
-
.replace(/>/gi, ">")
|
|
2028
|
-
.replace(/&/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
|