@velum-labs/routekit-tool-cursor 0.18.6 → 1.0.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 +4 -5
- package/dist/driver.d.ts +1 -1
- package/dist/driver.js +91 -73
- package/dist/launch.js +1 -1
- package/dist/test/driver.test.js +22 -0
- package/package.json +14 -6
package/README.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# @velum-labs/routekit-tool-cursor
|
|
2
2
|
|
|
3
|
-
Product-neutral
|
|
3
|
+
Product-neutral Cursor custom-endpoint setup and canonical ACP driver.
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
7
|
-
This package
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
request to the retained `/v1/cursor` door. RouteKit also does not proxy or
|
|
7
|
+
This package is internal. Cursor Desktop and `cursor-agent` are not current
|
|
8
|
+
RouteKit client-support surfaces. Cursor Desktop 3.12.30 rejected RouteKit model
|
|
9
|
+
names before sending a request to `/v1/cursor`. RouteKit also does not proxy or
|
|
11
10
|
emulate Cursor's backend protocol, so `cursor-agent` cannot use the gateway.
|
|
12
11
|
|
|
13
12
|
## Usage
|
package/dist/driver.d.ts
CHANGED
package/dist/driver.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
|
4
|
+
import { AsyncChannel, asHarnessError, buildChildEnv, createCachedHarnessDriver, DEFAULT_AUTOMATION_APPROVAL_POLICY, decideApproval, HarnessError, nowIso, PendingRequests, probeCliVersion, resolveDriverEnv, resumeStringField, SessionResourceRegistry, SingleFlightTurnController, terminate } from "@velum-labs/routekit-harness-core";
|
|
3
5
|
import { z } from "zod";
|
|
4
|
-
import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk";
|
|
5
|
-
import { AsyncChannel, HarnessError, DEFAULT_AUTOMATION_APPROVAL_POLICY, PendingRequests, asHarnessError, buildChildEnv, createCachedHarnessDriver, decideApproval, probeCliVersion, resolveDriverEnv, terminate } from "@velum-labs/routekit-harness-core";
|
|
6
6
|
const RESUME_CURSOR_VERSION = 1;
|
|
7
7
|
const DEFAULT_COMMAND = "cursor-agent";
|
|
8
8
|
const AUTH_METHOD_ID = "cursor_login";
|
|
@@ -16,9 +16,6 @@ export const cursorDriverConfigSchema = z.object({
|
|
|
16
16
|
endpoint: z.string().optional(),
|
|
17
17
|
model: z.string().optional()
|
|
18
18
|
});
|
|
19
|
-
function nowIso() {
|
|
20
|
-
return new Date().toISOString();
|
|
21
|
-
}
|
|
22
19
|
function cursorReasoningEffort(reasoning) {
|
|
23
20
|
if (reasoning === undefined || reasoning.mode === "auto")
|
|
24
21
|
return undefined;
|
|
@@ -82,6 +79,7 @@ class CursorSession {
|
|
|
82
79
|
#turnId;
|
|
83
80
|
#openItems = new Set();
|
|
84
81
|
#stopped = false;
|
|
82
|
+
#turns = new SingleFlightTurnController();
|
|
85
83
|
constructor(input) {
|
|
86
84
|
this.#child = input.child;
|
|
87
85
|
this.#connection = input.connection;
|
|
@@ -108,12 +106,24 @@ class CursorSession {
|
|
|
108
106
|
switch (update.sessionUpdate) {
|
|
109
107
|
case "agent_message_chunk":
|
|
110
108
|
if (update.content.type === "text") {
|
|
111
|
-
channel.push({
|
|
109
|
+
channel.push({
|
|
110
|
+
...base,
|
|
111
|
+
type: "content.delta",
|
|
112
|
+
stream: "assistant_text",
|
|
113
|
+
text: update.content.text,
|
|
114
|
+
raw
|
|
115
|
+
});
|
|
112
116
|
}
|
|
113
117
|
return;
|
|
114
118
|
case "agent_thought_chunk":
|
|
115
119
|
if (update.content.type === "text") {
|
|
116
|
-
channel.push({
|
|
120
|
+
channel.push({
|
|
121
|
+
...base,
|
|
122
|
+
type: "content.delta",
|
|
123
|
+
stream: "reasoning_text",
|
|
124
|
+
text: update.content.text,
|
|
125
|
+
raw
|
|
126
|
+
});
|
|
117
127
|
}
|
|
118
128
|
return;
|
|
119
129
|
case "tool_call": {
|
|
@@ -215,77 +225,88 @@ class CursorSession {
|
|
|
215
225
|
async *sendTurn(input) {
|
|
216
226
|
if (this.#stopped)
|
|
217
227
|
throw new HarnessError("session_closed", "cursor session is stopped");
|
|
218
|
-
const
|
|
228
|
+
const turn = this.#turns.start(input.signal);
|
|
229
|
+
const channel = new AsyncChannel({
|
|
230
|
+
capacity: 256,
|
|
231
|
+
onConsumerReturn: () => turn.dispose()
|
|
232
|
+
});
|
|
219
233
|
this.#channel = channel;
|
|
220
234
|
this.#turnId = `${this.#sessionId}:turn:${Date.now()}`;
|
|
221
235
|
const turnId = this.#turnId;
|
|
222
236
|
const base = { kind: this.#kind, sessionId: this.#sessionId, at: nowIso(), turnId };
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// it cannot resolve as completed off a prompt the agent ignored.
|
|
226
|
-
if (input.signal?.aborted === true) {
|
|
227
|
-
channel.push({ ...base, type: "turn.completed", endReason: "aborted" });
|
|
228
|
-
channel.close();
|
|
229
|
-
try {
|
|
230
|
-
yield* channel;
|
|
231
|
-
}
|
|
232
|
-
finally {
|
|
233
|
-
this.#channel = undefined;
|
|
234
|
-
this.#turnId = undefined;
|
|
235
|
-
}
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (input.reasoning !== undefined &&
|
|
239
|
-
JSON.stringify(input.reasoning) !== JSON.stringify(this.#reasoning)) {
|
|
240
|
-
const effort = cursorReasoningEffort(input.reasoning);
|
|
241
|
-
if (effort !== undefined) {
|
|
242
|
-
await this.#connection.extMethod("session/set_config_option", {
|
|
243
|
-
sessionId: this.#sessionId,
|
|
244
|
-
configId: "reasoning",
|
|
245
|
-
value: effort
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
this.#reasoning = input.reasoning;
|
|
249
|
-
}
|
|
237
|
+
let abortAttached = false;
|
|
238
|
+
let settled = false;
|
|
250
239
|
const onAbort = () => {
|
|
251
240
|
this.#pending.settleAll("cancel");
|
|
252
241
|
void this.#connection.cancel({ sessionId: this.#sessionId }).catch(() => undefined);
|
|
253
242
|
};
|
|
254
|
-
if (input.signal !== undefined) {
|
|
255
|
-
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
256
|
-
}
|
|
257
|
-
this.#connection
|
|
258
|
-
.prompt({
|
|
259
|
-
sessionId: this.#sessionId,
|
|
260
|
-
prompt: [{ type: "text", text: input.prompt }]
|
|
261
|
-
})
|
|
262
|
-
.then((response) => {
|
|
263
|
-
channel.push({
|
|
264
|
-
...base,
|
|
265
|
-
type: "turn.completed",
|
|
266
|
-
endReason: response.stopReason === "cancelled" ? "aborted" : "completed",
|
|
267
|
-
raw: { source: "acp.prompt.response", payload: { stopReason: response.stopReason } }
|
|
268
|
-
});
|
|
269
|
-
channel.close();
|
|
270
|
-
})
|
|
271
|
-
.catch((error) => {
|
|
272
|
-
const harnessError = asHarnessError(error);
|
|
273
|
-
channel.push({
|
|
274
|
-
...base,
|
|
275
|
-
type: "turn.failed",
|
|
276
|
-
errorCode: harnessError.code,
|
|
277
|
-
message: harnessError.message
|
|
278
|
-
});
|
|
279
|
-
channel.close();
|
|
280
|
-
});
|
|
281
243
|
try {
|
|
244
|
+
channel.push({ ...base, type: "turn.started" });
|
|
245
|
+
// An already-aborted turn never reaches the agent: settle it directly so
|
|
246
|
+
// it cannot resolve as completed off a prompt the agent ignored.
|
|
247
|
+
if (turn.signal.aborted) {
|
|
248
|
+
channel.push({ ...base, type: "turn.completed", endReason: "aborted" });
|
|
249
|
+
channel.close();
|
|
250
|
+
yield* channel;
|
|
251
|
+
turn.complete();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (input.reasoning !== undefined &&
|
|
255
|
+
JSON.stringify(input.reasoning) !== JSON.stringify(this.#reasoning)) {
|
|
256
|
+
const effort = cursorReasoningEffort(input.reasoning);
|
|
257
|
+
if (effort !== undefined) {
|
|
258
|
+
await this.#connection.extMethod("session/set_config_option", {
|
|
259
|
+
sessionId: this.#sessionId,
|
|
260
|
+
configId: "reasoning",
|
|
261
|
+
value: effort
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
this.#reasoning = input.reasoning;
|
|
265
|
+
}
|
|
266
|
+
turn.signal.addEventListener("abort", onAbort, { once: true });
|
|
267
|
+
abortAttached = true;
|
|
268
|
+
this.#connection
|
|
269
|
+
.prompt({
|
|
270
|
+
sessionId: this.#sessionId,
|
|
271
|
+
prompt: [{ type: "text", text: input.prompt }]
|
|
272
|
+
})
|
|
273
|
+
.then((response) => {
|
|
274
|
+
settled = true;
|
|
275
|
+
channel.push({
|
|
276
|
+
...base,
|
|
277
|
+
type: "turn.completed",
|
|
278
|
+
endReason: response.stopReason === "cancelled" ? "aborted" : "completed",
|
|
279
|
+
raw: { source: "acp.prompt.response", payload: { stopReason: response.stopReason } }
|
|
280
|
+
});
|
|
281
|
+
channel.close();
|
|
282
|
+
})
|
|
283
|
+
.catch((error) => {
|
|
284
|
+
settled = true;
|
|
285
|
+
const harnessError = asHarnessError(error);
|
|
286
|
+
channel.push({
|
|
287
|
+
...base,
|
|
288
|
+
type: "turn.failed",
|
|
289
|
+
errorCode: harnessError.code,
|
|
290
|
+
message: harnessError.message
|
|
291
|
+
});
|
|
292
|
+
channel.close();
|
|
293
|
+
});
|
|
282
294
|
yield* channel;
|
|
283
295
|
}
|
|
284
296
|
finally {
|
|
285
|
-
|
|
297
|
+
if (abortAttached)
|
|
298
|
+
turn.signal.removeEventListener("abort", onAbort);
|
|
299
|
+
if (turn.signal.aborted) {
|
|
300
|
+
this.#pending.settleAll("cancel");
|
|
301
|
+
await this.#connection.cancel({ sessionId: this.#sessionId }).catch(() => undefined);
|
|
302
|
+
}
|
|
303
|
+
channel.close();
|
|
286
304
|
this.#channel = undefined;
|
|
287
305
|
this.#turnId = undefined;
|
|
288
306
|
this.#openItems.clear();
|
|
307
|
+
if (settled)
|
|
308
|
+
turn.complete();
|
|
309
|
+
turn.dispose();
|
|
289
310
|
}
|
|
290
311
|
}
|
|
291
312
|
async respondToRequest(requestId, decision) {
|
|
@@ -294,6 +315,7 @@ class CursorSession {
|
|
|
294
315
|
}
|
|
295
316
|
}
|
|
296
317
|
async interrupt() {
|
|
318
|
+
this.#turns.interrupt();
|
|
297
319
|
this.#pending.settleAll("cancel");
|
|
298
320
|
await this.#connection.cancel({ sessionId: this.#sessionId }).catch(() => undefined);
|
|
299
321
|
}
|
|
@@ -308,23 +330,21 @@ class CursorSession {
|
|
|
308
330
|
if (this.#stopped)
|
|
309
331
|
return;
|
|
310
332
|
this.#stopped = true;
|
|
333
|
+
this.#turns.interrupt(new Error("cursor session stopped"));
|
|
311
334
|
this.#pending.settleAll("cancel");
|
|
312
335
|
this.#channel?.close();
|
|
313
336
|
terminate(this.#child);
|
|
314
337
|
}
|
|
315
338
|
}
|
|
316
339
|
function resumeSessionId(resume) {
|
|
317
|
-
|
|
318
|
-
return undefined;
|
|
319
|
-
const data = resume.data;
|
|
320
|
-
return typeof data.sessionId === "string" ? data.sessionId : undefined;
|
|
340
|
+
return resumeStringField(resume, "cursor", "sessionId");
|
|
321
341
|
}
|
|
322
342
|
class CursorInstance {
|
|
323
343
|
kind = "cursor";
|
|
324
344
|
#config;
|
|
325
345
|
#context;
|
|
326
346
|
#status;
|
|
327
|
-
#sessions = new
|
|
347
|
+
#sessions = new SessionResourceRegistry();
|
|
328
348
|
constructor(config, context, status) {
|
|
329
349
|
this.#config = config;
|
|
330
350
|
this.#context = context;
|
|
@@ -334,6 +354,7 @@ class CursorInstance {
|
|
|
334
354
|
return this.#status;
|
|
335
355
|
}
|
|
336
356
|
async startSession(options) {
|
|
357
|
+
this.#sessions.assertOpen();
|
|
337
358
|
const args = this.#config.endpoint !== undefined ? ["-e", this.#config.endpoint, "acp"] : ["acp"];
|
|
338
359
|
const child = spawn(this.#config.command, args, {
|
|
339
360
|
cwd: options.cwd,
|
|
@@ -394,8 +415,7 @@ class CursorInstance {
|
|
|
394
415
|
approvalPolicy: options.approvalPolicy ?? DEFAULT_AUTOMATION_APPROVAL_POLICY,
|
|
395
416
|
reasoning: options.reasoning
|
|
396
417
|
});
|
|
397
|
-
this.#sessions.
|
|
398
|
-
return session;
|
|
418
|
+
return this.#sessions.manage(session);
|
|
399
419
|
}
|
|
400
420
|
catch (error) {
|
|
401
421
|
terminate(child);
|
|
@@ -403,9 +423,7 @@ class CursorInstance {
|
|
|
403
423
|
}
|
|
404
424
|
}
|
|
405
425
|
async dispose() {
|
|
406
|
-
|
|
407
|
-
await session.stop();
|
|
408
|
-
this.#sessions.clear();
|
|
426
|
+
await this.#sessions.dispose();
|
|
409
427
|
}
|
|
410
428
|
}
|
|
411
429
|
/** Probe cursor-agent: version via `cursor-agent --version`. */
|
package/dist/launch.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cursorModelName, EFFORT_QUALIFIED_MODEL_CODEC, effortQualifiedClientModel } from "@velum-labs/routekit-contracts";
|
|
2
|
-
import { normalizeApiBaseUrl } from "@velum-labs/routekit-runtime";
|
|
2
|
+
import { normalizeApiBaseUrl } from "@velum-labs/routekit-runtime/network";
|
|
3
3
|
import { scaffoldCursorSubagents } from "./subagents.js";
|
|
4
4
|
/** Base URL for Cursor's "Override OpenAI Base URL" setting. */
|
|
5
5
|
export function cursorByokBaseUrl(gatewayUrl) {
|
package/dist/test/driver.test.js
CHANGED
|
@@ -70,6 +70,28 @@ test("cursor driver forwards effort through the ACP config option", async () =>
|
|
|
70
70
|
await instance.dispose();
|
|
71
71
|
}
|
|
72
72
|
});
|
|
73
|
+
test("cursor driver releases the turn after reasoning validation fails", async () => {
|
|
74
|
+
const instance = await driver.createInstance(driver.configSchema.parse({ command: wrapperCommand() }));
|
|
75
|
+
try {
|
|
76
|
+
const session = await instance.startSession({ cwd: here });
|
|
77
|
+
await assert.rejects(async () => {
|
|
78
|
+
for await (const _event of session.sendTurn({
|
|
79
|
+
prompt: "invalid override",
|
|
80
|
+
reasoning: { mode: "budget", budgetTokens: 1024 }
|
|
81
|
+
})) {
|
|
82
|
+
// Drain.
|
|
83
|
+
}
|
|
84
|
+
}, /cannot represent reasoning mode "budget"/);
|
|
85
|
+
const events = [];
|
|
86
|
+
for await (const event of session.sendTurn({ prompt: "valid retry" })) {
|
|
87
|
+
events.push(event);
|
|
88
|
+
}
|
|
89
|
+
assert.equal(events.find((event) => event.type === "turn.completed")?.endReason, "completed");
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await instance.dispose();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
73
95
|
test("cursor driver auto-approves under the automation policy", async () => {
|
|
74
96
|
const instance = await driver.createInstance(driver.configSchema.parse({ command: wrapperCommand() }));
|
|
75
97
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-tool-cursor",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
16
|
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./driver": {
|
|
19
|
+
"types": "./dist/driver.d.ts",
|
|
20
|
+
"default": "./dist/driver.js"
|
|
21
|
+
},
|
|
22
|
+
"./launch": {
|
|
23
|
+
"types": "./dist/launch.d.ts",
|
|
24
|
+
"default": "./dist/launch.js"
|
|
17
25
|
}
|
|
18
26
|
},
|
|
19
27
|
"files": [
|
|
@@ -28,10 +36,10 @@
|
|
|
28
36
|
"dependencies": {
|
|
29
37
|
"@agentclientprotocol/sdk": "0.4.5",
|
|
30
38
|
"zod": "4.4.3",
|
|
31
|
-
"@velum-labs/routekit-contracts": "0.
|
|
32
|
-
"@velum-labs/routekit-harness-core": "0.
|
|
33
|
-
"@velum-labs/routekit-runtime": "0.
|
|
34
|
-
"@velum-labs/routekit-tools": "0.
|
|
39
|
+
"@velum-labs/routekit-contracts": "1.0.0",
|
|
40
|
+
"@velum-labs/routekit-harness-core": "1.0.0",
|
|
41
|
+
"@velum-labs/routekit-runtime": "1.0.0",
|
|
42
|
+
"@velum-labs/routekit-tools": "1.0.0"
|
|
35
43
|
},
|
|
36
44
|
"keywords": [
|
|
37
45
|
"llm",
|
|
@@ -41,7 +49,7 @@
|
|
|
41
49
|
"adapter"
|
|
42
50
|
],
|
|
43
51
|
"scripts": {
|
|
44
|
-
"build": "tsc -
|
|
52
|
+
"build": "tsc -p tsconfig.json",
|
|
45
53
|
"clean": "tsc -b --clean",
|
|
46
54
|
"test": "node --test \"dist/test/*.test.js\""
|
|
47
55
|
}
|