authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 18:30:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 18:30:57-07:00
logb5e4f1e0b0c2c06f183ab6a89072bec21c8334c4
treee4e9c75a7de0a530f0cc449d2ed9f0d62899bbf4
parente62161bc5b53389a462d066836d47d25df0f4134
signature Signed by PGP key B5690EEEBB952194

refactor(provider-cursor): move Cursor into its own provider package (#17349)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

91 files changed, 9161 insertions(+), 9089 deletions(-)

apps/mobile/package.json+1
...@@ -70,6 +70,7 @@...@@ -70,6 +70,7 @@
70 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",70 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
71 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",71 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
72 "@t3tools/provider-core": "workspace:*",72 "@t3tools/provider-core": "workspace:*",
73 "@t3tools/provider-cursor": "workspace:*",
73 "@t3tools/provider-muse": "workspace:*",74 "@t3tools/provider-muse": "workspace:*",
74 "@t3tools/provider-opencode": "workspace:*",75 "@t3tools/provider-opencode": "workspace:*",
75 "@t3tools/provider-pi": "workspace:*",76 "@t3tools/provider-pi": "workspace:*",
apps/mobile/src/components/ProviderIcon.tsx-11
...@@ -109,17 +109,6 @@ export function ProviderIcon(props: ProviderIconProps) {...@@ -109,17 +109,6 @@ export function ProviderIcon(props: ProviderIconProps) {
109 );109 );
110 }110 }
111111
112 if (props.provider === "cursor") {
113 return (
114 <Svg width={size} height={size} viewBox="0 0 466.73 532.09" fill="none">
115 <Path
116 fill={isDarkMode ? "#EDECEC" : "#26251E"}
117 d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
118 />
119 </Svg>
120 );
121 }
122
123 const packageIcon = getProviderClient(props.provider)?.icon;112 const packageIcon = getProviderClient(props.provider)?.icon;
124 if (packageIcon) {113 if (packageIcon) {
125 const themeFill = (fill: { readonly light: string; readonly dark: string }) =>114 const themeFill = (fill: { readonly light: string; readonly dark: string }) =>
apps/mobile/src/lib/providerClients.ts+7-1
...@@ -1,11 +1,17 @@...@@ -1,11 +1,17 @@
1import { isProviderDriverKind } from "@t3tools/contracts";1import { isProviderDriverKind } from "@t3tools/contracts";
2import { makeProviderClientRegistry } from "@t3tools/provider-core/client";2import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
3import { cursorClient } from "@t3tools/provider-cursor/client";
3import { museClient } from "@t3tools/provider-muse/client";4import { museClient } from "@t3tools/provider-muse/client";
4import { openCodeClient } from "@t3tools/provider-opencode/client";5import { openCodeClient } from "@t3tools/provider-opencode/client";
5import { piClient } from "@t3tools/provider-pi/client";6import { piClient } from "@t3tools/provider-pi/client";
67
7/** The provider client definitions this mobile build ships. */8/** The provider client definitions this mobile build ships. */
8const providerClients = makeProviderClientRegistry([museClient, openCodeClient, piClient]);9const providerClients = makeProviderClientRegistry([
10 cursorClient,
11 museClient,
12 openCodeClient,
13 piClient,
14]);
915
10/** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */16/** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */
11export function getProviderClient(driver: string | null | undefined) {17export function getProviderClient(driver: string | null | undefined) {
apps/server/package.json+1
...@@ -39,6 +39,7 @@...@@ -39,6 +39,7 @@
39 "@noble/hashes": "catalog:",39 "@noble/hashes": "catalog:",
40 "@opencode-ai/sdk": "^1.3.15",40 "@opencode-ai/sdk": "^1.3.15",
41 "@t3tools/provider-core": "workspace:*",41 "@t3tools/provider-core": "workspace:*",
42 "@t3tools/provider-cursor": "workspace:*",
42 "@t3tools/provider-muse": "workspace:*",43 "@t3tools/provider-muse": "workspace:*",
43 "@t3tools/provider-opencode": "workspace:*",44 "@t3tools/provider-opencode": "workspace:*",
44 "@t3tools/provider-pi": "workspace:*",45 "@t3tools/provider-pi": "workspace:*",
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts deleted-956
...@@ -1,956 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import type { InteractionUpdate } from "@cursor/sdk";
4import {
5 CursorSettings,
6 EnvironmentId,
7 MessageId,
8 NodeId,
9 ProjectId,
10 ProviderInstanceId,
11 ProviderSessionId,
12 RunAttemptId,
13 RunId,
14 ThreadId,
15} from "@t3tools/contracts";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
24
25import * as ServerConfig from "../../config.ts";
26import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
27import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
28import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";
29import {
30 cursorMcpServers,
31 cursorRuntimeAgentPolicy,
32 cursorSdkModelSelection,
33 makeCursorAgentOptions,
34 makeCursorAdapterV2,
35 nestedToolCallFromEnvelope,
36} from "./CursorAdapterV2.ts";
37import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAgentSdk.ts";
38
39const decodeCursorSettings = Schema.decodeEffect(CursorSettings);
40
41describe("CursorAdapterV2", () => {
42 it.effect.each([
43 { status: "finished", model: undefined, lateModel: undefined },
44 { status: "cancelled", model: "claude-opus-4-6", lateModel: undefined },
45 { status: "error", model: "custom-fable", lateModel: undefined },
46 { status: "finished", model: undefined, lateModel: "gpt-6-sol" },
47 { status: "finished", model: "gpt-6-sol", lateModel: null },
48 ] as const)(
49 "projects Cursor tasks: $status, late model $lateModel",
50 ({ status, model, lateModel }) =>
51 Effect.gen(function* () {
52 const fileSystem = yield* FileSystem.FileSystem;
53 const path = yield* Path.Path;
54 const workspace = yield* fileSystem.makeTempDirectoryScoped({
55 prefix: "cursor-v2-lifecycle-",
56 });
57 const instanceId = ProviderInstanceId.make("cursor");
58 const threadId = ThreadId.make("cursor-lifecycle-thread");
59 const modelSelection = { instanceId, model: "composer-2.5" };
60 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
61 runtimeMode: "full-access",
62 interactionMode: "default",
63 cwd: workspace,
64 });
65 const adapter = makeCursorAdapterV2({
66 instanceId,
67 settings: yield* decodeCursorSettings({}),
68 environment: { HOME: workspace },
69 fileSystem,
70 path,
71 idAllocator: yield* IdAllocator.IdAllocatorV2,
72 serverConfig: yield* ServerConfig.ServerConfig.pipe(
73 Effect.provide(
74 ServerConfig.layerTest(workspace, { prefix: "cursor-v2-lifecycle-config-" }),
75 ),
76 ),
77 runner: {
78 assertComplete: Effect.void,
79 open: () =>
80 Effect.succeed({
81 agentId: "native-cursor-lifecycle",
82 listMessages: Effect.succeed([]),
83 close: Effect.void,
84 send: (input) =>
85 Effect.gen(function* () {
86 // Recorded Cursor task calls (see fixtures/subagent) stream a
87 // partial-tool-call before tool-call-started with the same args.
88 // The run then ends without tool-call-completed, which a live
89 // run cannot produce on demand.
90 const taskToolCall = {
91 type: "task" as const,
92 args: {
93 description: "Review",
94 prompt: "Review the code.",
95 subagentType: { kind: "unspecified" },
96 ...(model === undefined ? {} : { model }),
97 agentId: "cursor-task-agent",
98 mode: "unspecified" as const,
99 },
100 };
101 const updates =
102 lateModel !== undefined
103 ? (["tool-call-started", "tool-call-completed"] as const)
104 : (["partial-tool-call", "tool-call-started"] as const);
105 for (const type of updates) {
106 yield* input.onDelta!({
107 type,
108 modelCallId: "model-call",
109 callId: "task-call",
110 toolCall: {
111 ...taskToolCall,
112 args: {
113 ...taskToolCall.args,
114 ...(type === "tool-call-completed"
115 ? { model: lateModel ?? undefined }
116 : {}),
117 },
118 },
119 }).pipe(Effect.orDie);
120 }
121 return {
122 agentId: "native-cursor-lifecycle",
123 runId: "native-cursor-run",
124 wait: Effect.succeed({
125 id: "native-cursor-run",
126 requestId: "native-request",
127 status,
128 model: { id: "composer-2.5" },
129 durationMs: 1,
130 }),
131 cancel: Effect.void,
132 };
133 }),
134 }),
135 },
136 });
137 const runtime = yield* adapter.openSession({
138 threadId,
139 providerSessionId: ProviderSessionId.make("cursor-lifecycle-session"),
140 modelSelection,
141 runtimePolicy,
142 });
143 const providerThread = yield* runtime.ensureThread({
144 threadId,
145 modelSelection,
146 runtimePolicy,
147 });
148 const now = yield* DateTime.now;
149 yield* runtime.startTurn({
150 threadId,
151 providerThread,
152 modelSelection,
153 runtimePolicy,
154 runId: RunId.make("cursor-lifecycle-run"),
155 runOrdinal: 1,
156 providerTurnOrdinal: 1,
157 attemptId: RunAttemptId.make("cursor-lifecycle-attempt"),
158 rootNodeId: NodeId.make("cursor-lifecycle-root"),
159 appThread: {
160 id: threadId,
161 projectId: ProjectId.make("cursor-lifecycle-project"),
162 createdBy: "user",
163 creationSource: "web",
164 title: "Cursor lifecycle",
165 providerInstanceId: instanceId,
166 modelSelection,
167 runtimeMode: "full-access",
168 interactionMode: "default",
169 branch: null,
170 worktreePath: null,
171 activeProviderThreadId: providerThread.id,
172 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
173 forkedFrom: null,
174 createdAt: now,
175 updatedAt: now,
176 archivedAt: null,
177 settledOverride: null,
178 settledAt: null,
179 lastVisitedAt: null,
180 deletedAt: null,
181 },
182 message: {
183 messageId: MessageId.make("cursor-lifecycle-message"),
184 createdBy: "user",
185 creationSource: "web",
186 text: "Review the code.",
187 attachments: [],
188 },
189 });
190 const events = yield* runtime.events.pipe(
191 Stream.takeUntil((event) => event.type === "turn.terminal"),
192 Stream.runCollect,
193 );
194 const rows = events.filter((event) => event.type === "subagent.updated");
195 assert.equal(rows[0]?.subagent.status, "running");
196 assert.equal(rows[0]?.subagent.model, model ?? null);
197 assert.equal(rows.at(-1)?.subagent.model, lateModel ?? model ?? null);
198 assert.equal(
199 rows.at(-1)?.subagent.status,
200 lateModel !== undefined
201 ? "completed"
202 : status === "finished"
203 ? "idle"
204 : status === "cancelled"
205 ? "cancelled"
206 : "failed",
207 );
208 assert.isNotNull(rows.at(-1)?.subagent.completedAt);
209 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
210 );
211
212 it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () =>
213 Effect.gen(function* () {
214 const fileSystem = yield* FileSystem.FileSystem;
215 const path = yield* Path.Path;
216 const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" });
217 const sentMessages: Array<string> = [];
218 let sendCalls = 0;
219 const instanceId = ProviderInstanceId.make("cursor");
220 const threadId = ThreadId.make("cursor-transport-error-thread");
221 const modelSelection = { instanceId, model: "composer-2.5" };
222 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
223 runtimeMode: "full-access",
224 interactionMode: "default",
225 cwd: workspace,
226 });
227 const adapter = makeCursorAdapterV2({
228 instanceId,
229 settings: yield* decodeCursorSettings({}),
230 environment: { HOME: workspace },
231 fileSystem,
232 path,
233 idAllocator: yield* IdAllocator.IdAllocatorV2,
234 serverConfig: yield* ServerConfig.ServerConfig.pipe(
235 Effect.provide(ServerConfig.layerTest(workspace, { prefix: "cursor-v2-error-config-" })),
236 ),
237 runner: {
238 assertComplete: Effect.void,
239 open: () =>
240 Effect.succeed({
241 agentId: "native-cursor-error",
242 listMessages: Effect.succeed([]),
243 close: Effect.void,
244 send: (input) =>
245 Effect.sync(() => {
246 sendCalls += 1;
247 sentMessages.push(
248 typeof input.message === "string" ? input.message : input.message.text,
249 );
250 const runId = `native-cursor-run-${sendCalls}`;
251 return {
252 agentId: "native-cursor-error",
253 runId,
254 wait: Effect.succeed({
255 id: runId,
256 requestId: `native-request-${sendCalls}`,
257 status: "finished" as const,
258 model: { id: "composer-2.5" },
259 durationMs: 1,
260 ...(sendCalls === 1
261 ? { result: "Error: RetriableError: WritableIterable is closed" }
262 : {}),
263 }),
264 cancel: Effect.void,
265 };
266 }),
267 }),
268 },
269 });
270 const runtime = yield* adapter.openSession({
271 threadId,
272 providerSessionId: ProviderSessionId.make("cursor-error-session"),
273 modelSelection,
274 runtimePolicy,
275 });
276 const providerThread = yield* runtime.ensureThread({
277 threadId,
278 modelSelection,
279 runtimePolicy,
280 });
281 const now = yield* DateTime.now;
282 const appThread = {
283 id: threadId,
284 projectId: ProjectId.make("cursor-error-project"),
285 createdBy: "user" as const,
286 creationSource: "web" as const,
287 title: "Cursor transport failure",
288 providerInstanceId: instanceId,
289 modelSelection,
290 runtimeMode: "full-access" as const,
291 interactionMode: "default" as const,
292 branch: null,
293 worktreePath: null,
294 activeProviderThreadId: providerThread.id,
295 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
296 forkedFrom: null,
297 createdAt: now,
298 updatedAt: now,
299 archivedAt: null,
300 settledOverride: null,
301 settledAt: null,
302 lastVisitedAt: null,
303 deletedAt: null,
304 };
305 yield* runtime.startTurn({
306 threadId,
307 providerThread,
308 modelSelection,
309 runtimePolicy,
310 runId: RunId.make("cursor-error-run"),
311 runOrdinal: 1,
312 providerTurnOrdinal: 1,
313 attemptId: RunAttemptId.make("cursor-error-attempt"),
314 rootNodeId: NodeId.make("cursor-error-root"),
315 appThread,
316 message: {
317 messageId: MessageId.make("cursor-error-message"),
318 createdBy: "user",
319 creationSource: "web",
320 text: "continue",
321 attachments: [],
322 },
323 });
324 const first = yield* runtime.events.pipe(
325 Stream.filter((event) => event.type === "turn.terminal"),
326 Stream.runHead,
327 );
328 assert.isTrue(Option.isSome(first));
329 if (Option.isSome(first)) {
330 assert.equal(first.value.status, "failed");
331 assert.equal(first.value.failure?.class, "transport_error");
332 }
333
334 assert.isDefined(runtime.compactThread);
335 yield* runtime.compactThread!({
336 threadId,
337 providerThread,
338 modelSelection,
339 runtimePolicy,
340 runId: RunId.make("cursor-compact-run"),
341 runOrdinal: 2,
342 providerTurnOrdinal: 2,
343 attemptId: RunAttemptId.make("cursor-compact-attempt"),
344 rootNodeId: NodeId.make("cursor-compact-root"),
345 appThread,
346 message: {
347 messageId: MessageId.make("cursor-compact-message"),
348 createdBy: "user",
349 creationSource: "web",
350 text: "ignored by compactThread",
351 attachments: [],
352 },
353 });
354 yield* runtime.events.pipe(
355 Stream.filter((event) => event.type === "turn.terminal"),
356 Stream.runHead,
357 );
358 assert.equal(sentMessages[1], "/compress");
359 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
360 );
361
362 it.effect("projects Cursor directory trees and lint diagnostics as file search results", () =>
363 Effect.gen(function* () {
364 const fileSystem = yield* FileSystem.FileSystem;
365 const path = yield* Path.Path;
366 const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-search-" });
367 const instanceId = ProviderInstanceId.make("cursor");
368 const threadId = ThreadId.make("cursor-search-thread");
369 const modelSelection = { instanceId, model: "composer-2.5" };
370 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
371 runtimeMode: "full-access",
372 interactionMode: "default",
373 cwd: workspace,
374 });
375 const emptyLsNode = {
376 childrenDirs: [],
377 childrenFiles: [],
378 childrenWereProcessed: true,
379 fullSubtreeExtensionCounts: {},
380 numFiles: 0,
381 };
382 const updates: ReadonlyArray<InteractionUpdate> = [
383 ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({
384 type,
385 modelCallId: "native-model-call",
386 callId: "mcp-weather",
387 toolCall: {
388 type: "mcp" as const,
389 args: {
390 providerIdentifier: "weather",
391 toolName: "get_weather",
392 args: { city: "Berlin" },
393 },
394 ...(type === "tool-call-completed"
395 ? { result: { status: "success" as const, value: { content: [], isError: false } } }
396 : {}),
397 },
398 })),
399 {
400 type: "tool-call-completed",
401 modelCallId: "native-model-call",
402 callId: "read-file",
403 toolCall: {
404 type: "read",
405 args: { path: "src/env.ts" },
406 result: {
407 status: "success",
408 value: { fileSize: 12, content: "---\nfile body", totalLines: 2 },
409 },
410 },
411 },
412 {
413 type: "tool-call-completed",
414 modelCallId: "native-model-call",
415 callId: "ls-nested",
416 toolCall: {
417 type: "ls",
418 args: { path: workspace },
419 result: {
420 status: "success",
421 value: {
422 directoryTreeRoot: {
423 ...emptyLsNode,
424 absPath: workspace,
425 childrenFiles: [{ name: "README.md" }],
426 childrenDirs: [
427 {
428 ...emptyLsNode,
429 absPath: path.join(workspace, "src"),
430 childrenFiles: [{ name: "index.ts" }],
431 childrenDirs: [
432 {
433 ...emptyLsNode,
434 absPath: path.join(workspace, "src", "nested"),
435 childrenFiles: [{ name: "util.ts" }],
436 },
437 ],
438 },
439 ],
440 },
441 },
442 },
443 },
444 },
445 {
446 type: "tool-call-completed",
447 modelCallId: "native-model-call",
448 callId: "ls-empty",
449 toolCall: {
450 type: "ls",
451 args: { path: path.join(workspace, "empty") },
452 result: {
453 status: "success",
454 value: {
455 directoryTreeRoot: { ...emptyLsNode, absPath: path.join(workspace, "empty") },
456 },
457 },
458 },
459 },
460 {
461 type: "tool-call-completed",
462 modelCallId: "native-model-call",
463 callId: "ls-failed",
464 toolCall: {
465 type: "ls",
466 args: { path: path.join(workspace, "missing") },
467 result: { status: "error", error: "ENOENT" },
468 },
469 },
470 {
471 type: "tool-call-completed",
472 modelCallId: "native-model-call",
473 callId: "grep-failed",
474 toolCall: {
475 type: "grep",
476 args: { pattern: "TODO", path: "src" },
477 result: { status: "error", error: "search failed" },
478 },
479 },
480 {
481 type: "tool-call-completed",
482 modelCallId: "native-model-call",
483 callId: "glob-failed",
484 toolCall: {
485 type: "glob",
486 args: { globPattern: "*.ts" },
487 result: { status: "error", error: "search failed" },
488 },
489 },
490 {
491 type: "tool-call-completed",
492 modelCallId: "native-model-call",
493 callId: "lints",
494 toolCall: {
495 type: "readLints",
496 args: { paths: ["src/a.ts", "src/b.ts"] },
497 result: {
498 status: "success",
499 value: {
500 totalFiles: 2,
501 totalDiagnostics: 3,
502 fileDiagnostics: [
503 {
504 path: "src/a.ts",
505 diagnosticsCount: 2,
506 diagnostics: [
507 {
508 message: "Unused variable",
509 code: "TS6133",
510 source: "ts",
511 severity: "warning",
512 range: { start: { line: 0, character: 4 } },
513 },
514 {
515 message: "Cannot find name",
516 code: "TS2304",
517 source: "ts",
518 severity: "error",
519 range: { start: { line: 11 } },
520 },
521 ],
522 },
523 {
524 path: "src/b.ts",
525 diagnosticsCount: 1,
526 diagnostics: [
527 {
528 message: "File-level diagnostic",
529 code: "TS0",
530 source: "ts",
531 severity: "information",
532 },
533 ],
534 },
535 ],
536 },
537 },
538 },
539 },
540 {
541 type: "tool-call-completed",
542 modelCallId: "native-model-call",
543 callId: "lints-empty",
544 toolCall: {
545 type: "readLints",
546 args: { paths: ["src/clean.ts"] },
547 result: {
548 status: "success",
549 value: {
550 totalFiles: 1,
551 totalDiagnostics: 0,
552 fileDiagnostics: [{ path: "src/clean.ts", diagnosticsCount: 0, diagnostics: [] }],
553 },
554 },
555 },
556 },
557 {
558 type: "tool-call-completed",
559 modelCallId: "native-model-call",
560 callId: "lints-failed",
561 toolCall: {
562 type: "readLints",
563 args: { paths: ["src/a.ts"] },
564 result: { status: "error", error: "lint failed" },
565 },
566 },
567 ];
568 const adapter = makeCursorAdapterV2({
569 instanceId,
570 settings: yield* decodeCursorSettings({}),
571 environment: { HOME: workspace },
572 fileSystem,
573 path,
574 idAllocator: yield* IdAllocator.IdAllocatorV2,
575 serverConfig: yield* ServerConfig.ServerConfig.pipe(
576 Effect.provide(ServerConfig.layerTest(workspace, { prefix: "cursor-v2-search-config-" })),
577 ),
578 runner: {
579 assertComplete: Effect.void,
580 open: () =>
581 Effect.succeed({
582 agentId: "native-cursor-search",
583 listMessages: Effect.succeed([]),
584 close: Effect.void,
585 send: (input) =>
586 Effect.gen(function* () {
587 for (const update of updates) {
588 if (input.onDelta !== undefined) {
589 yield* input.onDelta(update).pipe(Effect.orDie);
590 }
591 }
592 return {
593 agentId: "native-cursor-search",
594 runId: "native-cursor-run",
595 wait: Effect.succeed({
596 id: "native-cursor-run",
597 requestId: "native-request",
598 status: "finished" as const,
599 model: { id: "composer-2.5" },
600 durationMs: 1,
601 }),
602 cancel: Effect.void,
603 };
604 }),
605 }),
606 },
607 });
608 const runtime = yield* adapter.openSession({
609 threadId,
610 providerSessionId: ProviderSessionId.make("cursor-search-session"),
611 modelSelection,
612 runtimePolicy,
613 });
614 const providerThread = yield* runtime.ensureThread({
615 threadId,
616 modelSelection,
617 runtimePolicy,
618 });
619 const now = yield* DateTime.now;
620 yield* runtime.startTurn({
621 threadId,
622 providerThread,
623 modelSelection,
624 runtimePolicy,
625 runId: RunId.make("cursor-search-run"),
626 runOrdinal: 1,
627 providerTurnOrdinal: 1,
628 attemptId: RunAttemptId.make("cursor-search-attempt"),
629 rootNodeId: NodeId.make("cursor-search-root"),
630 appThread: {
631 id: threadId,
632 projectId: ProjectId.make("cursor-search-project"),
633 createdBy: "user",
634 creationSource: "web",
635 title: "Cursor search results",
636 providerInstanceId: instanceId,
637 modelSelection,
638 runtimeMode: "full-access",
639 interactionMode: "default",
640 branch: null,
641 worktreePath: null,
642 activeProviderThreadId: providerThread.id,
643 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
644 forkedFrom: null,
645 createdAt: now,
646 updatedAt: now,
647 archivedAt: null,
648 settledOverride: null,
649 settledAt: null,
650 lastVisitedAt: null,
651 deletedAt: null,
652 },
653 message: {
654 messageId: MessageId.make("cursor-search-message"),
655 createdBy: "user",
656 creationSource: "web",
657 text: "inspect the workspace",
658 attachments: [],
659 },
660 });
661 const events = yield* runtime.events.pipe(
662 Stream.takeUntil((event) => event.type === "turn.terminal"),
663 Stream.runCollect,
664 );
665 const mcpItems = events.flatMap((event) =>
666 event.type === "turn_item.updated" &&
667 event.turnItem.type === "dynamic_tool" &&
668 event.turnItem.toolName === "mcp__weather__get_weather"
669 ? [event.turnItem]
670 : [],
671 );
672 assert.deepEqual(
673 mcpItems.map((item) => item.status),
674 ["running", "completed"],
675 );
676 for (const item of mcpItems) {
677 assert.equal(item.title, "get weather");
678 assert.deepEqual(item.toolSource, {
679 key: "mcp:weather",
680 name: "weather",
681 kind: "integration",
682 });
683 assert.deepEqual(item.input, {
684 providerIdentifier: "weather",
685 toolName: "get_weather",
686 args: { city: "Berlin" },
687 });
688 }
689 const fileSearchItems = events.flatMap((event) =>
690 event.type === "turn_item.updated" &&
691 event.turnItem.type === "file_search" &&
692 event.turnItem.status !== "running"
693 ? [event.turnItem]
694 : [],
695 );
696 const readItems = events.flatMap((event) =>
697 event.type === "turn_item.updated" &&
698 event.turnItem.type === "dynamic_tool" &&
699 event.turnItem.toolName === "Read" &&
700 event.turnItem.status === "completed"
701 ? [event.turnItem]
702 : [],
703 );
704 assert.deepEqual(
705 readItems.map((item) => ({ title: item.title, input: item.input })),
706 [{ title: "Read src/env.ts", input: { path: "src/env.ts" } }],
707 );
708 assert.deepEqual(
709 fileSearchItems.map((item) => ({
710 pattern: item.pattern,
711 status: item.status,
712 results: item.results,
713 })),
714 [
715 {
716 pattern: workspace,
717 status: "completed",
718 results: [
719 { fileName: path.join(workspace, "README.md") },
720 { fileName: path.join(workspace, "src", "index.ts") },
721 { fileName: path.join(workspace, "src", "nested", "util.ts") },
722 ],
723 },
724 {
725 pattern: path.join(workspace, "empty"),
726 status: "completed",
727 results: undefined,
728 },
729 {
730 pattern: path.join(workspace, "missing"),
731 status: "failed",
732 results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }],
733 },
734 {
735 pattern: "TODO",
736 status: "failed",
737 results: [{ fileName: "src", preview: "search failed" }],
738 },
739 {
740 pattern: "*.ts",
741 status: "failed",
742 results: [{ fileName: ".", preview: "search failed" }],
743 },
744 {
745 pattern: "src/a.ts, src/b.ts",
746 status: "completed",
747 results: [
748 { fileName: "src/a.ts", line: 1, column: 5, preview: "Unused variable" },
749 { fileName: "src/a.ts", line: 12, preview: "Cannot find name" },
750 { fileName: "src/b.ts", preview: "File-level diagnostic" },
751 ],
752 },
753 {
754 pattern: "src/clean.ts",
755 status: "completed",
756 results: undefined,
757 },
758 {
759 pattern: "src/a.ts",
760 status: "failed",
761 results: [{ fileName: "src/a.ts", preview: "lint failed" }],
762 },
763 ],
764 );
765 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
766 );
767
768 it("maps Cursor auto and model parameters to SDK selections", () => {
769 assert.deepEqual(
770 cursorSdkModelSelection({
771 instanceId: ProviderInstanceId.make("cursor"),
772 model: "auto",
773 options: [
774 { id: "thinking", value: "high" },
775 { id: "contextWindow", value: "1m" },
776 { id: "fastMode", value: true },
777 ],
778 }),
779 {
780 id: "default",
781 params: [
782 { id: "thinking", value: "high" },
783 { id: "context", value: "1m" },
784 { id: "fast", value: "true" },
785 ],
786 },
787 );
788 });
789
790 it("maps runtime modes to the SDK sandbox and auto-review controls", () => {
791 const base = {
792 interactionMode: "default" as const,
793 cwd: "/tmp/cursor-adapter",
794 };
795 assert.deepEqual(
796 cursorRuntimeAgentPolicy({
797 ...base,
798 runtimeMode: "full-access",
799 }),
800 {
801 autoReview: false,
802 sandboxEnabled: false,
803 },
804 );
805 assert.deepEqual(
806 cursorRuntimeAgentPolicy({
807 ...base,
808 runtimeMode: "auto-accept-edits",
809 }),
810 {
811 autoReview: false,
812 sandboxEnabled: true,
813 },
814 );
815 assert.deepEqual(
816 cursorRuntimeAgentPolicy({
817 ...base,
818 runtimeMode: "approval-required",
819 }),
820 {
821 autoReview: true,
822 sandboxEnabled: true,
823 },
824 );
825 assert.deepEqual(
826 cursorRuntimeAgentPolicy({
827 ...base,
828 runtimeMode: "full-access",
829 approvalPolicy: "never",
830 sandboxPolicy: { type: "readOnly" },
831 }),
832 {
833 autoReview: false,
834 sandboxEnabled: true,
835 },
836 );
837 assert.deepEqual(
838 cursorRuntimeAgentPolicy({
839 ...base,
840 runtimeMode: "approval-required",
841 approvalPolicy: "never",
842 sandboxPolicy: { type: "dangerFullAccess" },
843 }),
844 {
845 autoReview: false,
846 sandboxEnabled: false,
847 },
848 );
849 });
850
851 it("loads the user's Cursor settings layers in every runtime mode", () => {
852 // The SDK loads no rules, skills, hooks, or MCP config from disk unless
853 // settingSources names them, so an omitted list silently drops AGENTS.md.
854 for (const runtimeMode of ["full-access", "auto-accept-edits", "approval-required"] as const) {
855 const options = makeCursorAgentOptions({
856 modelSelection: {
857 instanceId: ProviderInstanceId.make("cursor"),
858 model: "composer-2.5",
859 },
860 runtimePolicy: { runtimeMode, interactionMode: "default", cwd: "/workspace" },
861 threadId: ThreadId.make("thread-cursor-setting-sources"),
862 });
863 assert.deepEqual(options.local?.settingSources, [
864 "project",
865 "user",
866 "team",
867 "mdm",
868 "plugins",
869 ]);
870 }
871 });
872
873 it("injects thread-scoped MCP credentials without logging them", () => {
874 const threadId = ThreadId.make("thread-cursor-mcp");
875 McpProviderSession.setMcpProviderSession({
876 environmentId: EnvironmentId.make("environment-cursor-mcp"),
877 threadId,
878 providerSessionId: "mcp-session-cursor",
879 providerInstanceId: ProviderInstanceId.make("cursor"),
880 endpoint: "http://127.0.0.1:43123/mcp",
881 authorizationHeader: "Bearer secret-cursor-mcp-token",
882 browserToolsAvailable: true,
883 });
884
885 try {
886 assert.deepEqual(cursorMcpServers(threadId), {
887 "t3-code": {
888 type: "http",
889 url: "http://127.0.0.1:43123/mcp",
890 headers: {
891 Authorization: "Bearer secret-cursor-mcp-token",
892 },
893 },
894 });
895
896 const options = makeCursorAgentOptions({
897 apiKey: "secret-cursor-api-key",
898 modelSelection: {
899 instanceId: ProviderInstanceId.make("cursor"),
900 model: "composer-2.5",
901 },
902 runtimePolicy: {
903 runtimeMode: "full-access",
904 interactionMode: "default",
905 cwd: "/workspace",
906 },
907 threadId,
908 });
909 assert.deepEqual(options.mcpServers, cursorMcpServers(threadId));
910
911 const logged = JSON.stringify(loggedCursorAgentOptions(options));
912 assert.notInclude(logged, "secret-cursor-api-key");
913 assert.notInclude(logged, "secret-cursor-mcp-token");
914 } finally {
915 McpProviderSession.clearMcpProviderSession(threadId);
916 }
917 });
918
919 it("recognizes direct and SDK-wrapped abort failures as cancellation", () => {
920 assert.isTrue(isCursorCancellationError({ name: "AbortError" }));
921 assert.isTrue(
922 isCursorCancellationError({
923 name: "ConnectError",
924 cause: {
925 name: "ConnectError",
926 cause: { name: "AbortError" },
927 },
928 }),
929 );
930 assert.isFalse(isCursorCancellationError(new Error("request failed")));
931 assert.isFalse(isCursorCancellationError(null));
932 });
933
934 it("preserves failed nested read calls when Cursor omits their path", () => {
935 assert.deepEqual(
936 nestedToolCallFromEnvelope({
937 toolCallId: "tool:failed-read",
938 readToolCall: {
939 args: {},
940 result: { error: "File path was not provided." },
941 },
942 }),
943 {
944 callId: "tool:failed-read",
945 toolCall: {
946 type: "read",
947 args: { path: "<unknown path>" },
948 result: {
949 status: "error",
950 error: "File path was not provided.",
951 },
952 },
953 },
954 );
955 });
956});
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts+5-98
...@@ -1,6 +1,4 @@...@@ -1,6 +1,4 @@
1import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";1import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
2import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
3import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
4import type { InteractionUpdate, RunResult } from "@cursor/sdk";2import type { InteractionUpdate, RunResult } from "@cursor/sdk";
5import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
6import {4import {
...@@ -15,23 +13,21 @@ import * as Effect from "effect/Effect";...@@ -15,23 +13,21 @@ import * as Effect from "effect/Effect";
15import * as Fiber from "effect/Fiber";13import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";14import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
18import * as Path from "effect/Path";
19import * as PlatformError from "effect/PlatformError";
20import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
2117
22import * as ServerConfig from "../../config.ts";18import { layerTestProviderHost } from "@t3tools/provider-testing/host";
23import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";19import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
24import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver";20import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver";
25import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";21import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
26import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";22import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
27import * as CursorAgentSdk from "./CursorAgentSdk.ts";23import { CursorAdapterV2Driver } from "@t3tools/provider-cursor/server";
24import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk";
28import {25import {
29 CURSOR_DEFAULT_INSTANCE_ID,26 CURSOR_DEFAULT_INSTANCE_ID,
30 CURSOR_DRIVER_KIND,27 CURSOR_DRIVER_KIND,
31 CursorAdapterV2Driver,
32 cursorSdkModelSelection,28 cursorSdkModelSelection,
33 makeCursorAgentOptions,29 makeCursorAgentOptions,
34} from "./CursorAdapterV2.ts";30} from "@t3tools/provider-cursor/testing";
35import type { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";31import type { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";
36import type { RuntimePolicyV2Override } from "../RuntimePolicy.ts";32import type { RuntimePolicyV2Override } from "../RuntimePolicy.ts";
3733
...@@ -513,91 +509,6 @@ function layerCursorAgentSdkReplay(...@@ -513,91 +509,6 @@ function layerCursorAgentSdkReplay(
513 );509 );
514}510}
515511
516function makeReplayServerConfig(
517 scenario: string,
518): Effect.Effect<
519 ServerConfig.ServerConfig["Service"],
520 PlatformError.PlatformError,
521 FileSystem.FileSystem | Path.Path
522> {
523 return Effect.gen(function* () {
524 const fs = yield* FileSystem.FileSystem;
525 const path = yield* Path.Path;
526 const baseDir = yield* fs.makeTempDirectory({
527 prefix: `t3-orchestration-v2-cursor-${scenario}-`,
528 });
529 const stateDir = path.join(baseDir, "userdata");
530 const logsDir = path.join(stateDir, "logs");
531 const providerLogsDir = path.join(logsDir, "provider");
532 const terminalLogsDir = path.join(logsDir, "terminals");
533 const attachmentsDir = path.join(stateDir, "attachments");
534 const environmentThemesDir = path.join(stateDir, "themes");
535 const worktreesDir = path.join(baseDir, "worktrees");
536 const providerStatusCacheDir = path.join(baseDir, "caches");
537 for (const directory of [
538 stateDir,
539 logsDir,
540 providerLogsDir,
541 terminalLogsDir,
542 attachmentsDir,
543 environmentThemesDir,
544 worktreesDir,
545 providerStatusCacheDir,
546 ]) {
547 yield* fs.makeDirectory(directory, { recursive: true });
548 }
549 return {
550 logLevel: "Error",
551 traceMinLevel: "Info",
552 traceTimingEnabled: true,
553 traceBatchWindowMs: 200,
554 traceMaxBytes: 10 * 1024 * 1024,
555 traceMaxFiles: 10,
556 otelEnvironment: OtelEnvironment.none,
557 otlpTracesUrl: undefined,
558 otlpMetricsUrl: undefined,
559 otlpLogsUrl: undefined,
560 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
561 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
562 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
563 mode: "web",
564 port: 0,
565 host: undefined,
566 cwd: process.cwd(),
567 baseDir,
568 staticDir: undefined,
569 devUrl: undefined,
570 devAllowedOrigins: [],
571 noBrowser: false,
572 startupPresentation: "browser",
573 tailscaleServeEnabled: false,
574 tailscaleServePort: 443,
575 desktopBootstrapToken: undefined,
576 autoBootstrapProjectFromCwd: false,
577 logWebSocketEvents: false,
578 stateDir,
579 dbPath: path.join(stateDir, "state.sqlite"),
580 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
581 settingsPath: path.join(stateDir, "settings.json"),
582 providerStatusCacheDir,
583 worktreesDir,
584 attachmentsDir,
585 browserArtifactsDir: path.join(stateDir, "browser-artifacts"),
586 environmentThemesDir,
587 logsDir,
588 serverLogPath: path.join(logsDir, "server.log"),
589 serverTracePath: path.join(logsDir, "server.trace.ndjson"),
590 providerLogsDir,
591 providerEventLogPath: path.join(providerLogsDir, "events.log"),
592 terminalLogsDir,
593 anonymousIdPath: path.join(stateDir, "anonymous-id"),
594 environmentIdPath: path.join(stateDir, "environment-id"),
595 serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"),
596 secretsDir: path.join(stateDir, "secrets"),
597 };
598 });
599}
600
601export function layer(512export function layer(
602 transcript: CursorAgentSdkReplayTranscript,513 transcript: CursorAgentSdkReplayTranscript,
603 options?: {514 options?: {
...@@ -605,10 +516,6 @@ export function layer(...@@ -605,10 +516,6 @@ export function layer(
605 readonly assertCompleteOnFinalize?: boolean;516 readonly assertCompleteOnFinalize?: boolean;
606 },517 },
607) {518) {
608 const layerServerConfig = Layer.effect(
609 ServerConfig.ServerConfig,
610 makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie),
611 ).pipe(Layer.provide(NodeServices.layer));
612 // Skill discovery also scans user roots under HOME; an empty HOME keeps519 // Skill discovery also scans user roots under HOME; an empty HOME keeps
613 // replays from picking up the host's own skills.520 // replays from picking up the host's own skills.
614 const layerHostEnvironment = Layer.effect(521 const layerHostEnvironment = Layer.effect(
...@@ -630,7 +537,7 @@ export function layer(...@@ -630,7 +537,7 @@ export function layer(
630 Layer.provide(537 Layer.provide(
631 Layer.mergeAll(538 Layer.mergeAll(
632 layerCursorAgentSdkReplay(transcript, options),539 layerCursorAgentSdkReplay(transcript, options),
633 layerServerConfig,540 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
634 layerHostEnvironment,541 layerHostEnvironment,
635 NodeServices.layer,542 NodeServices.layer,
636 IdAllocator.layer,543 IdAllocator.layer,
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts deleted-2685
...@@ -1,2685 +0,0 @@
1import type {
2 AgentMessage,
3 AgentOptions,
4 InteractionUpdate,
5 McpServerConfig,
6 RunResult,
7 SDKUserMessage,
8 SettingSource,
9 ToolCall,
10} from "@cursor/sdk";
11import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
12import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
13import {
14 CursorSettings,
15 isOrchestrationV2WorkActive,
16 defaultInstanceIdForDriver,
17 type ChatAttachment,
18 type ModelSelection,
19 type OrchestrationV2ConversationMessage,
20 type OrchestrationV2ExecutionNode,
21 type OrchestrationV2PlanArtifact,
22 type OrchestrationV2PlanStep,
23 type OrchestrationV2ProviderCapabilities,
24 type OrchestrationV2ProviderFailure,
25 type OrchestrationV2ProviderSession,
26 type OrchestrationV2ProviderThread,
27 type OrchestrationV2ProviderTurn,
28 type OrchestrationV2Subagent,
29 type OrchestrationV2TurnItem,
30 type ProviderInstanceId,
31 type ThreadId,
32} from "@t3tools/contracts";
33import * as DateTime from "effect/DateTime";
34import * as Deferred from "effect/Deferred";
35import * as Effect from "effect/Effect";
36import * as FileSystem from "effect/FileSystem";
37import * as Layer from "effect/Layer";
38import * as Option from "effect/Option";
39import * as Path from "effect/Path";
40import * as Queue from "effect/Queue";
41import * as Ref from "effect/Ref";
42import * as Schema from "effect/Schema";
43import * as Stream from "effect/Stream";
44
45import { resolveAttachmentPath } from "../../attachmentStore.ts";
46import * as ServerConfig from "../../config.ts";
47import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation";
48import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
49import { CursorTransportFailure } from "../../provider/acp/CursorTransportFailure.ts";
50import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts";
51import {
52 discoverCursorSkills,
53 hasCursorSkillMention,
54 rewriteCursorSkillMentions,
55} from "../../provider/Drivers/CursorSkills.ts";
56import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
57import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions";
58import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
59import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
60import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
61import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
62import {
63 isProviderNativeImageAttachment,
64 providerMessageTextWithAttachmentPaths,
65} from "@t3tools/provider-core/server/attachmentPrompt";
66import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
67import {
68 ProviderAdapterDriverCreateError,
69 type ProviderAdapterDriver,
70 type ProviderAdapterDriverCreateInput,
71} from "@t3tools/provider-core/server/adapterDriver";
72import {
73 makeSubagentChildThread,
74 makeSubagentConversationArtifacts,
75 subagentThreadTitle,
76} from "@t3tools/provider-core/server/subagentProjection";
77import * as CursorAgentSdk from "./CursorAgentSdk.ts";
78export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts";
79
80export const CURSOR_DRIVER_KIND = CursorAgentSdk.CURSOR_PROVIDER;
81export const CURSOR_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CURSOR_DRIVER_KIND);
82const DEFAULT_CURSOR_SETTINGS = Schema.decodeSync(CursorSettings)({});
83
84export const CursorProviderCapabilitiesV2 = {
85 sessions: {
86 supportsMultipleProviderThreadsPerSession: false,
87 supportsModelSwitchInSession: true,
88 supportsProviderSwitchingViaHandoff: true,
89 supportsRuntimeModeSwitchInSession: false,
90 pendingRequestsSurviveRestart: false,
91 },
92 threads: {
93 canCreateEmptyThread: true,
94 canReadThreadSnapshot: true,
95 canRollbackThread: false,
96 canForkThread: false,
97 canForkFromTurn: false,
98 canForkFromSubagentThread: false,
99 exposesNativeThreadId: true,
100 },
101 turns: {
102 exposesNativeTurnId: true,
103 emitsTurnStarted: true,
104 emitsTurnCompleted: true,
105 supportsInterrupt: true,
106 supportsActiveSteering: false,
107 supportsSteeringByInterruptRestart: true,
108 supportsQueuedMessages: true,
109 terminalStatusQuality: "strong",
110 },
111 streaming: {
112 streamsAssistantText: true,
113 streamsReasoning: true,
114 streamsToolOutput: true,
115 streamsPlanText: true,
116 emitsMessageCompleted: true,
117 },
118 tools: {
119 exposesToolItemIds: true,
120 emitsToolStarted: true,
121 emitsToolCompleted: true,
122 emitsToolOutput: true,
123 supportsMcpTools: true,
124 supportsDynamicToolCallbacks: false,
125 },
126 approvals: {
127 supportsCommandApproval: false,
128 supportsFileReadApproval: false,
129 supportsFileChangeApproval: false,
130 supportsApplyPatchApproval: false,
131 approvalsHaveNativeRequestIds: false,
132 approvalCallbacksAreLiveOnly: false,
133 approvalsCanOriginateFromSubagents: false,
134 },
135 planning: {
136 emitsPlanUpdated: true,
137 emitsTodoList: true,
138 emitsProposedPlan: true,
139 supportsStructuredQuestions: false,
140 planDeltasHaveItemIds: true,
141 },
142 subagents: {
143 supportsSubagents: true,
144 exposesSubagentThreadIds: false,
145 emitsSubagentLifecycle: true,
146 canWaitForSubagents: true,
147 canCloseSubagents: false,
148 canForkSubagentThread: false,
149 },
150 context: {
151 acceptsSystemContext: false,
152 acceptsDeveloperContext: false,
153 acceptsSyntheticUserContext: true,
154 canGenerateSummaries: true,
155 canConsumeHandoffSummaries: true,
156 supportsDeltaHandoff: true,
157 supportsFullThreadHandoff: true,
158 maxRecommendedHandoffChars: null,
159 },
160 checkpointing: {
161 appCanCheckpointFilesystem: true,
162 supportsNestedCheckpointScopes: true,
163 providerCanRollbackConversation: false,
164 providerRollbackReturnsSnapshot: false,
165 providerCanReadConversationSnapshot: true,
166 },
167 identity: {
168 nativeThreadIds: "strong",
169 nativeTurnIds: "strong",
170 nativeItemIds: "weak",
171 nativeRequestIds: "none",
172 },
173 runtimePolicy: {
174 enforcement: "native",
175 },
176} satisfies OrchestrationV2ProviderCapabilities;
177
178export interface CursorRuntimeAgentPolicy {
179 readonly autoReview: boolean;
180 readonly sandboxEnabled: boolean;
181}
182
183export function cursorRuntimeAgentPolicy(
184 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
185): CursorRuntimeAgentPolicy {
186 const sandboxPolicyType =
187 typeof runtimePolicy.sandboxPolicy === "object" &&
188 runtimePolicy.sandboxPolicy !== null &&
189 "type" in runtimePolicy.sandboxPolicy &&
190 typeof runtimePolicy.sandboxPolicy.type === "string"
191 ? runtimePolicy.sandboxPolicy.type
192 : undefined;
193 return {
194 autoReview:
195 runtimePolicy.approvalPolicy === undefined
196 ? runtimePolicy.runtimeMode === "approval-required"
197 : runtimePolicy.approvalPolicy !== "never",
198 sandboxEnabled:
199 runtimePolicy.sandboxPolicy === undefined
200 ? runtimePolicy.runtimeMode !== "full-access"
201 : sandboxPolicyType !== "dangerFullAccess",
202 };
203}
204
205export function cursorMcpServers(threadId: ThreadId): Record<string, McpServerConfig> | undefined {
206 const session = McpProviderSession.readMcpProviderSession(threadId);
207 if (session === undefined) {
208 return undefined;
209 }
210 return {
211 "t3-code": {
212 type: "http",
213 url: session.endpoint,
214 headers: {
215 Authorization: session.authorizationHeader,
216 },
217 },
218 };
219}
220
221function providerSession(input: {
222 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
223 readonly providerInstanceId: ProviderInstanceId;
224 readonly cwd: string | null;
225 readonly model: string;
226 readonly now: DateTime.Utc;
227}): OrchestrationV2ProviderSession {
228 return {
229 id: input.providerSessionId,
230 driver: CursorAgentSdk.CURSOR_PROVIDER,
231 providerInstanceId: input.providerInstanceId,
232 status: "ready",
233 cwd: input.cwd ?? process.cwd(),
234 model: input.model,
235 capabilities: CursorProviderCapabilitiesV2,
236 createdAt: input.now,
237 updatedAt: input.now,
238 lastError: null,
239 };
240}
241
242function makeProviderThread(input: {
243 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
244 readonly providerInstanceId: ProviderInstanceId;
245 readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
246 readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
247 readonly nativeThreadId: string;
248 readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
249 readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"];
250 readonly now: DateTime.Utc;
251}): OrchestrationV2ProviderThread {
252 return {
253 id: input.idAllocator.derive.providerThread({
254 driver: CursorAgentSdk.CURSOR_PROVIDER,
255 nativeThreadId: input.nativeThreadId,
256 }),
257 driver: CursorAgentSdk.CURSOR_PROVIDER,
258 providerInstanceId: input.providerInstanceId,
259 providerSessionId: input.providerSessionId,
260 appThreadId: input.appThreadId,
261 ownerNodeId: input.ownerNodeId ?? null,
262 nativeThreadRef: {
263 driver: CursorAgentSdk.CURSOR_PROVIDER,
264 nativeId: input.nativeThreadId,
265 strength: "strong",
266 },
267 nativeConversationHeadRef: null,
268 status: "idle",
269 firstRunOrdinal: null,
270 lastRunOrdinal: null,
271 handoffIds: [],
272 forkedFrom: input.forkedFrom ?? null,
273 createdAt: input.now,
274 updatedAt: input.now,
275 };
276}
277
278function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string {
279 const id = providerThread.nativeThreadRef?.nativeId;
280 if (id === null || id === undefined) {
281 throw new ProviderAdapter.ProviderAdapterProtocolError({
282 driver: CursorAgentSdk.CURSOR_PROVIDER,
283 detail: `Provider thread ${providerThread.id} is missing its Cursor agent id.`,
284 });
285 }
286 return id;
287}
288
289/**
290 * Every Cursor settings layer the Cursor CLI loads: project and user rules,
291 * skills, hooks, and MCP servers, team and MDM admin policy, and account
292 * plugins. The SDK loads none of them when `settingSources` is omitted.
293 * Sandbox policy files are read either way, and hooks can only deny or ask
294 * (which local SDK runs reject), so these layers do not loosen the sandbox or
295 * approval mode T3 sets.
296 */
297const CURSOR_AGENT_SETTING_SOURCES = [
298 "project",
299 "user",
300 "team",
301 "mdm",
302 "plugins",
303] as const satisfies ReadonlyArray<SettingSource>;
304
305export function makeCursorAgentOptions(input: {
306 readonly apiKey?: string;
307 readonly modelSelection: ModelSelection;
308 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
309 readonly threadId: ThreadId;
310}): AgentOptions {
311 const policy = cursorRuntimeAgentPolicy(input.runtimePolicy);
312 const mcpServers = cursorMcpServers(input.threadId);
313 return {
314 model: cursorSdkModelSelection(input.modelSelection),
315 name: `T3 Code ${input.threadId}`,
316 mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "agent",
317 ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }),
318 local: {
319 ...(input.runtimePolicy.cwd === null ? {} : { cwd: input.runtimePolicy.cwd }),
320 autoReview: policy.autoReview,
321 settingSources: [...CURSOR_AGENT_SETTING_SOURCES],
322 sandboxOptions: {
323 enabled: policy.sandboxEnabled,
324 },
325 enableAgentRetries: true,
326 },
327 ...(mcpServers === undefined ? {} : { mcpServers }),
328 };
329}
330
331function stableJson(value: unknown): string {
332 try {
333 return JSON.stringify(value, null, 2);
334 } catch {
335 return String(value);
336 }
337}
338
339function unknownRecord(value: unknown): Record<string, unknown> | undefined {
340 return typeof value === "object" && value !== null && !Array.isArray(value)
341 ? (value as Record<string, unknown>)
342 : undefined;
343}
344
345function nestedString(value: unknown, keys: ReadonlyArray<string>): string | undefined {
346 let current: unknown = value;
347 for (const key of keys) {
348 current = unknownRecord(current)?.[key];
349 }
350 return typeof current === "string" ? current : undefined;
351}
352
353function cursorToolFailed(toolCall: ToolCall): boolean {
354 if (toolCall.result?.status === "error") {
355 return true;
356 }
357 return toolCall.type === "mcp" && toolCall.result?.status === "success"
358 ? toolCall.result.value.isError
359 : false;
360}
361
362function cursorToolOutput(toolCall: ToolCall): unknown {
363 const result = toolCall.result;
364 if (result === undefined) {
365 return undefined;
366 }
367 return result.status === "success" ? result.value : result.error;
368}
369
370function cursorToolOutputText(toolCall: ToolCall): string {
371 if (toolCall.type === "shell" && toolCall.result?.status === "success") {
372 return [toolCall.result.value.stdout, toolCall.result.value.stderr]
373 .filter((part) => part.length > 0)
374 .join("\n");
375 }
376 if (toolCall.type === "mcp" && toolCall.result?.status === "success") {
377 return toolCall.result.value.content
378 .flatMap((part) => (part.text === undefined ? [] : [part.text.text]))
379 .join("\n");
380 }
381 const output = cursorToolOutput(toolCall);
382 if (output === undefined) {
383 return "";
384 }
385 return typeof output === "string" ? output : stableJson(output);
386}
387
388function cursorToolName(toolCall: ToolCall): string {
389 if (toolCall.type !== "mcp") {
390 return toolCall.type;
391 }
392 const provider = toolCall.args.providerIdentifier ?? "mcp";
393 const tool = toolCall.args.toolName ?? "unknown";
394 return `mcp__${provider}__${tool}`;
395}
396
397function cursorToolFileName(toolCall: ToolCall): string {
398 switch (toolCall.type) {
399 case "write":
400 case "delete":
401 case "read":
402 case "edit":
403 case "ls":
404 return toolCall.args.path;
405 case "generateImage":
406 return toolCall.args.filePath ?? "generated-image";
407 default:
408 return cursorToolName(toolCall);
409 }
410}
411
412function cursorToolSearchPattern(toolCall: ToolCall): string | undefined {
413 switch (toolCall.type) {
414 case "glob":
415 return toolCall.args.globPattern;
416 case "grep":
417 return toolCall.args.pattern;
418 case "semSearch":
419 return toolCall.args.query;
420 case "ls":
421 return toolCall.args.path;
422 case "readLints":
423 return toolCall.args.paths.join(", ");
424 default:
425 return undefined;
426 }
427}
428
429type CursorLsDirectoryNode = Extract<
430 Extract<ToolCall, { readonly type: "ls" }>["result"],
431 { readonly status: "success" }
432>["value"]["directoryTreeRoot"];
433
434function cursorLsSearchResults(
435 node: CursorLsDirectoryNode,
436 path: Path.Path,
437): ReadonlyArray<{ readonly fileName: string }> {
438 return [
439 ...node.childrenFiles.map((file) => ({ fileName: path.join(node.absPath, file.name) })),
440 ...node.childrenDirs.flatMap((child) => cursorLsSearchResults(child, path)),
441 ];
442}
443
444function cursorToolSearchResults(
445 toolCall: ToolCall,
446 path: Path.Path,
447): ReadonlyArray<{
448 readonly fileName: string;
449 readonly line?: number;
450 readonly column?: number;
451 readonly preview?: string;
452}> {
453 if (toolCall.result?.status !== "success") {
454 return [];
455 }
456 switch (toolCall.type) {
457 case "glob":
458 return toolCall.result.value.files.map((fileName) => ({ fileName }));
459 case "grep":
460 return Object.values(toolCall.result.value.workspaceResults ?? {}).flatMap((result) => {
461 if (result.type === "files") {
462 return result.output.files.map((fileName) => ({ fileName }));
463 }
464 if (result.type === "count") {
465 return result.output.counts.map((entry) => ({
466 fileName: entry.file,
467 preview: `${entry.count} matches`,
468 }));
469 }
470 return result.output.matches.map((entry) => ({
471 fileName: entry.file,
472 ...(entry.lineNumber === undefined ? {} : { line: entry.lineNumber }),
473 preview: entry.line,
474 }));
475 });
476 case "ls":
477 return cursorLsSearchResults(toolCall.result.value.directoryTreeRoot, path);
478 case "readLints":
479 return toolCall.result.value.fileDiagnostics.flatMap((file) =>
480 file.diagnostics.map((diagnostic) => {
481 const line = diagnostic.range?.start?.line;
482 const character = diagnostic.range?.start?.character;
483 const hasLine = typeof line === "number" && Number.isInteger(line) && line >= 0;
484 return {
485 fileName: file.path,
486 ...(hasLine ? { line: line + 1 } : {}),
487 ...(hasLine &&
488 typeof character === "number" &&
489 Number.isInteger(character) &&
490 character >= 0
491 ? { column: character + 1 }
492 : {}),
493 preview: diagnostic.message,
494 };
495 }),
496 );
497 case "semSearch":
498 return [
499 {
500 fileName: "semantic-search",
501 preview: toolCall.result.value.results,
502 },
503 ];
504 default:
505 return [];
506 }
507}
508
509function cursorTodoSteps(
510 toolCall: Extract<ToolCall, { readonly type: "updateTodos" }>,
511): ReadonlyArray<OrchestrationV2PlanStep> {
512 const todos =
513 toolCall.result?.status === "success" ? toolCall.result.value.todos : toolCall.args.todos;
514 return todos
515 .filter((todo) => todo.status !== "cancelled" && todo.content.trim().length > 0)
516 .map((todo, index) => ({
517 id: `todo-${index + 1}`,
518 text: todo.content,
519 status:
520 todo.status === "inProgress"
521 ? "running"
522 : todo.status === "completed"
523 ? "completed"
524 : "pending",
525 }));
526}
527
528function assistantTextsFromConversationSteps(steps: ReadonlyArray<unknown>): ReadonlyArray<string> {
529 return steps.flatMap((step) => {
530 const record = unknownRecord(step);
531 if (record?.type === "assistantMessage") {
532 const text = nestedString(record, ["message", "text"]);
533 return text === undefined || text.length === 0 ? [] : [text];
534 }
535 const text = nestedString(record, ["assistantMessage", "text"]);
536 return text === undefined || text.length === 0 ? [] : [text];
537 });
538}
539
540function nestedToolResult<Value = Record<string, unknown>>(
541 rawResult: unknown,
542 mapSuccess: (success: Record<string, unknown>) => Value = (success) => success as Value,
543):
544 | { readonly status: "success"; readonly value: Value }
545 | { readonly status: "error"; readonly error: unknown }
546 | undefined {
547 const result = unknownRecord(rawResult);
548 if (result === undefined) {
549 return undefined;
550 }
551 const success = unknownRecord(result.success);
552 if (success !== undefined) {
553 return {
554 status: "success",
555 value: mapSuccess(success),
556 };
557 }
558 return {
559 status: "error",
560 error: result.error ?? result.permissionDenied ?? result,
561 };
562}
563
564function nestedGrepWorkspaceResults(success: Record<string, unknown>): Record<string, unknown> {
565 const workspaces = unknownRecord(success.workspaceResults);
566 if (workspaces === undefined) {
567 return {};
568 }
569 return Object.fromEntries(
570 Object.entries(workspaces).map(([workspace, rawWorkspaceResult]) => {
571 const workspaceResult = unknownRecord(rawWorkspaceResult);
572 const content = unknownRecord(workspaceResult?.content);
573 const rawMatches = Array.isArray(content?.matches) ? content.matches : [];
574 const matches = rawMatches.flatMap((rawFileMatch) => {
575 const fileMatch = unknownRecord(rawFileMatch);
576 const file = typeof fileMatch?.file === "string" ? fileMatch.file : "";
577 const lineMatches = Array.isArray(fileMatch?.matches) ? fileMatch.matches : [];
578 return lineMatches.flatMap((rawLineMatch) => {
579 const lineMatch = unknownRecord(rawLineMatch);
580 if (lineMatch === undefined) {
581 return [];
582 }
583 const line = typeof lineMatch.content === "string" ? lineMatch.content : undefined;
584 if (file.length === 0 || line === undefined) {
585 return [];
586 }
587 return [
588 {
589 file,
590 line,
591 ...(typeof lineMatch.lineNumber === "number"
592 ? { lineNumber: lineMatch.lineNumber }
593 : {}),
594 },
595 ];
596 });
597 });
598 return [
599 workspace,
600 {
601 type: "content",
602 output: {
603 matches,
604 totalMatches:
605 typeof content?.totalMatchedLines === "number"
606 ? content.totalMatchedLines
607 : matches.length,
608 },
609 },
610 ];
611 }),
612 );
613}
614
615export function nestedToolCallFromEnvelope(
616 envelope: Record<string, unknown>,
617): { readonly callId: string; readonly toolCall: ToolCall } | undefined {
618 const callId = typeof envelope.toolCallId === "string" ? envelope.toolCallId : undefined;
619 const wrapperEntry = Object.entries(envelope).find(([key]) => key.endsWith("ToolCall"));
620 if (wrapperEntry === undefined) {
621 return undefined;
622 }
623 const [wrapperName, rawCall] = wrapperEntry;
624 const call = unknownRecord(rawCall);
625 if (call === undefined) {
626 return undefined;
627 }
628 const args = unknownRecord(call.args) ?? {};
629 const fallbackCallId = `nested-${wrapperName}`;
630 const nestedCallId = callId ?? fallbackCallId;
631
632 switch (wrapperName) {
633 case "readToolCall": {
634 const success = unknownRecord(unknownRecord(call.result)?.success);
635 const path =
636 typeof args.path === "string"
637 ? args.path
638 : typeof success?.path === "string"
639 ? success.path
640 : "<unknown path>";
641 return {
642 callId: nestedCallId,
643 toolCall: {
644 type: "read",
645 args: { path },
646 result: nestedToolResult(call.result, (value) => ({
647 content: typeof value.content === "string" ? value.content : "",
648 totalLines: typeof value.totalLines === "number" ? value.totalLines : 0,
649 fileSize: typeof value.fileSize === "number" ? value.fileSize : 0,
650 })),
651 } as unknown as ToolCall,
652 };
653 }
654 case "globToolCall":
655 return {
656 callId: nestedCallId,
657 toolCall: {
658 type: "glob",
659 args: {
660 globPattern: typeof args.globPattern === "string" ? args.globPattern : "**/*",
661 ...(typeof args.targetDirectory === "string"
662 ? { targetDirectory: args.targetDirectory }
663 : {}),
664 },
665 result: nestedToolResult(call.result, (value) => ({
666 files: Array.isArray(value.files)
667 ? value.files.filter((file): file is string => typeof file === "string")
668 : [],
669 totalFiles: typeof value.totalFiles === "number" ? value.totalFiles : 0,
670 clientTruncated: false,
671 ripgrepTruncated: false,
672 })),
673 } as unknown as ToolCall,
674 };
675 case "grepToolCall":
676 return {
677 callId: nestedCallId,
678 toolCall: {
679 type: "grep",
680 args: {
681 pattern: typeof args.pattern === "string" ? args.pattern : "",
682 ...(typeof args.path === "string" ? { path: args.path } : {}),
683 ...(typeof args.glob === "string" ? { glob: args.glob } : {}),
684 ...(typeof args.outputMode === "string" ? { outputMode: args.outputMode } : {}),
685 ...(typeof args.caseInsensitive === "boolean"
686 ? { caseInsensitive: args.caseInsensitive }
687 : {}),
688 ...(typeof args.offset === "number" ? { offset: args.offset } : {}),
689 ...(typeof args.multiline === "boolean" ? { multiline: args.multiline } : {}),
690 },
691 result: nestedToolResult(call.result, (value) => ({
692 workspaceResults: nestedGrepWorkspaceResults(value),
693 })),
694 } as unknown as ToolCall,
695 };
696 case "shellToolCall": {
697 const rawResult = unknownRecord(call.result);
698 const permissionDenied = unknownRecord(rawResult?.permissionDenied);
699 const command =
700 typeof args.command === "string"
701 ? args.command
702 : typeof permissionDenied?.command === "string"
703 ? permissionDenied.command
704 : "<unknown command>";
705 return {
706 callId: nestedCallId,
707 toolCall: {
708 type: "shell",
709 args: { command },
710 result: nestedToolResult(call.result, (value) => ({
711 stdout: typeof value.stdout === "string" ? value.stdout : "",
712 stderr: typeof value.stderr === "string" ? value.stderr : "",
713 exitCode: typeof value.exitCode === "number" ? value.exitCode : 0,
714 signal: typeof value.signal === "string" ? value.signal : "",
715 executionTime: typeof value.executionTime === "number" ? value.executionTime : 0,
716 })),
717 } as unknown as ToolCall,
718 };
719 }
720 default: {
721 const type = wrapperName.slice(0, -"ToolCall".length);
722 return {
723 callId: nestedCallId,
724 toolCall: {
725 type,
726 args,
727 result: nestedToolResult(call.result),
728 } as unknown as ToolCall,
729 };
730 }
731 }
732}
733
734function toolCallsFromConversationSteps(
735 steps: ReadonlyArray<unknown>,
736): ReadonlyArray<{ readonly callId: string; readonly toolCall: ToolCall }> {
737 return steps.flatMap((step) => {
738 const record = unknownRecord(step);
739 if (record?.type === "toolCall") {
740 const message = unknownRecord(record.message);
741 return typeof message?.type === "string"
742 ? [
743 {
744 callId: typeof record.callId === "string" ? record.callId : `nested-${message.type}`,
745 toolCall: message as ToolCall,
746 },
747 ]
748 : [];
749 }
750 const envelope = unknownRecord(record?.toolCall);
751 const nested = envelope === undefined ? undefined : nestedToolCallFromEnvelope(envelope);
752 return nested === undefined ? [] : [nested];
753 });
754}
755
756function textFromAgentMessage(message: AgentMessage): string {
757 const value = message.message;
758 if (typeof value === "string") {
759 return value;
760 }
761 const direct =
762 nestedString(value, ["text"]) ??
763 nestedString(value, ["message", "text"]) ??
764 nestedString(value, ["userMessage", "text"]) ??
765 nestedString(value, ["assistantMessage", "text"]);
766 if (direct !== undefined) {
767 return direct;
768 }
769 const content = unknownRecord(value)?.content;
770 if (!Array.isArray(content)) {
771 return "";
772 }
773 return content
774 .flatMap((part) => {
775 const text = unknownRecord(part)?.text;
776 return typeof text === "string" ? [text] : [];
777 })
778 .join("");
779}
780
781interface CursorProjectionTarget {
782 readonly threadId: ThreadId;
783 readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null;
784 readonly rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"];
785 readonly parentNodeId: OrchestrationV2ExecutionNode["id"];
786 readonly providerThreadId: OrchestrationV2ProviderThread["id"] | null;
787 readonly providerTurnId: OrchestrationV2ProviderTurn["id"] | null;
788}
789
790interface ActiveCursorToolCall {
791 readonly callId: string;
792 toolCall: ToolCall;
793 readonly target: CursorProjectionTarget;
794 readonly ordinal: number;
795 readonly startedAt: DateTime.Utc;
796 streamedOutput: string;
797}
798
799interface ActiveCursorSubagent {
800 task: OrchestrationV2Subagent;
801 toolCall: Extract<ToolCall, { readonly type: "task" }>;
802 readonly callId: string;
803 readonly childThreadId: ThreadId;
804 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
805 readonly turnItemId: OrchestrationV2TurnItem["id"];
806 readonly turnItemOrdinal: number;
807 nextChildOrdinal: number;
808 resultProjected: boolean;
809}
810
811interface ActiveCursorTextSegment {
812 readonly nativeItemId: string;
813 readonly startedAt: DateTime.Utc;
814 text: string;
815}
816
817interface ActiveCursorTextStream {
818 current: ActiveCursorTextSegment | null;
819 nextSegment: number;
820}
821
822interface ActiveCursorTurn {
823 readonly input: ProviderAdapter.ProviderAdapterV2TurnInput;
824 readonly run: CursorAgentSdk.CursorAgentSdkRun;
825 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
826 readonly startedAt: DateTime.Utc;
827 readonly completed: Deferred.Deferred<void, never>;
828 // Item ordinals allocated in this turn. No later turn looks items up here.
829 readonly itemOrdinals: Map<string, number>;
830 readonly tools: Map<string, ActiveCursorToolCall>;
831 readonly subagents: Map<string, ActiveCursorSubagent>;
832 readonly assistant: ActiveCursorTextStream;
833 readonly assistantReply: CursorTransportFailure;
834 readonly reasoning: ActiveCursorTextStream;
835 interrupted: boolean;
836 finalized: boolean;
837}
838
839interface CursorLiveAgent {
840 readonly nativeThreadId: string;
841 readonly session: CursorAgentSdk.CursorAgentSdkSession;
842}
843
844export interface CursorAdapterV2Options {
845 readonly instanceId: ProviderInstanceId;
846 readonly settings: CursorSettings;
847 readonly environment: NodeJS.ProcessEnv;
848 readonly fileSystem: FileSystem.FileSystem;
849 readonly path: Path.Path;
850 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
851 readonly runner: CursorAgentSdk.CursorAgentSdkRunnerShape;
852 readonly serverConfig: ServerConfig.ServerConfig["Service"];
853}
854
855export function makeCursorAdapterV2(
856 adapterOptions: CursorAdapterV2Options,
857): ProviderAdapter.ProviderAdapterV2Shape {
858 const { fileSystem, path, idAllocator, runner, serverConfig } = adapterOptions;
859 const apiKey = adapterOptions.environment.CURSOR_API_KEY?.trim() || undefined;
860
861 return ProviderAdapter.ProviderAdapterV2.of({
862 instanceId: adapterOptions.instanceId,
863 driver: CursorAgentSdk.CURSOR_PROVIDER,
864 getCapabilities: () => Effect.succeed(CursorProviderCapabilitiesV2),
865 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
866 openSession: Effect.fn("CursorAdapterV2.openSession")(
867 function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) {
868 const sessionScope = yield* Effect.scope;
869 const createdAt = yield* DateTime.now;
870 const session = providerSession({
871 providerSessionId: input.providerSessionId,
872 providerInstanceId: adapterOptions.instanceId,
873 cwd: input.runtimePolicy.cwd,
874 model: input.modelSelection.model,
875 now: createdAt,
876 });
877 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
878 const liveAgent = yield* Ref.make<CursorLiveAgent | null>(null);
879 const activeTurn = yield* Ref.make<ActiveCursorTurn | null>(null);
880 const planIds = yield* Ref.make(new Map<string, OrchestrationV2PlanArtifact["id"]>());
881
882 const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) =>
883 Queue.offer(events, event).pipe(Effect.asVoid);
884
885 const resolveItemOrdinal = (context: ActiveCursorTurn, nativeItemId: string) =>
886 Effect.sync(() => {
887 const existing = context.itemOrdinals.get(nativeItemId);
888 if (existing !== undefined) {
889 return existing;
890 }
891 const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1;
892 context.itemOrdinals.set(nativeItemId, ordinal);
893 return ordinal;
894 });
895
896 const resolvePlanId = Effect.fnUntraced(function* (
897 context: ActiveCursorTurn,
898 nativeItemId: string,
899 ) {
900 const existing = (yield* Ref.get(planIds)).get(nativeItemId);
901 if (existing !== undefined) {
902 return existing;
903 }
904 const planId = yield* idAllocator.allocate.plan({
905 threadId: context.input.threadId,
906 runId: context.input.runId,
907 driver: CursorAgentSdk.CURSOR_PROVIDER,
908 });
909 yield* Ref.update(planIds, (current) => {
910 const updated = new Map(current);
911 updated.set(nativeItemId, planId);
912 return updated;
913 });
914 return planId;
915 });
916
917 const parentTarget = (context: ActiveCursorTurn): CursorProjectionTarget => ({
918 threadId: context.input.threadId,
919 runId: context.input.runId,
920 rootNodeId: context.input.rootNodeId,
921 parentNodeId: context.input.rootNodeId,
922 providerThreadId: context.input.providerThread.id,
923 providerTurnId: context.providerTurnId,
924 });
925
926 const emitAssistant = Effect.fnUntraced(function* (
927 context: ActiveCursorTurn,
928 completed: boolean,
929 ) {
930 const segment = context.assistant.current;
931 if (segment === null || segment.text.length === 0) {
932 return;
933 }
934 const now = yield* DateTime.now;
935 const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId);
936 const nodeId = idAllocator.derive.nodeFromProviderItem({
937 driver: CursorAgentSdk.CURSOR_PROVIDER,
938 nativeItemId: segment.nativeItemId,
939 });
940 const messageId = idAllocator.derive.messageFromProviderItem({
941 driver: CursorAgentSdk.CURSOR_PROVIDER,
942 nativeItemId: segment.nativeItemId,
943 });
944 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
945 driver: CursorAgentSdk.CURSOR_PROVIDER,
946 nativeItemId: segment.nativeItemId,
947 });
948 const nativeItemRef = {
949 driver: CursorAgentSdk.CURSOR_PROVIDER,
950 nativeId: segment.nativeItemId,
951 strength: "weak" as const,
952 };
953 yield* emitProviderEvent({
954 type: "node.updated",
955 driver: CursorAgentSdk.CURSOR_PROVIDER,
956 node: {
957 id: nodeId,
958 threadId: context.input.threadId,
959 runId: context.input.runId,
960 parentNodeId: context.input.rootNodeId,
961 rootNodeId: context.input.rootNodeId,
962 kind: "assistant_message",
963 status: completed ? "completed" : "running",
964 countsForRun: false,
965 providerThreadId: context.input.providerThread.id,
966 providerTurnId: context.providerTurnId,
967 nativeItemRef,
968 runtimeRequestId: null,
969 checkpointScopeId: null,
970 startedAt: segment.startedAt,
971 completedAt: completed ? now : null,
972 },
973 });
974 yield* emitProviderEvent({
975 type: "message.updated",
976 driver: CursorAgentSdk.CURSOR_PROVIDER,
977 message: {
978 createdBy: "agent",
979 creationSource: "provider",
980 id: messageId,
981 threadId: context.input.threadId,
982 runId: context.input.runId,
983 nodeId,
984 role: "assistant",
985 text: segment.text,
986 attachments: [],
987 streaming: !completed,
988 createdAt: segment.startedAt,
989 updatedAt: now,
990 },
991 });
992 yield* emitProviderEvent({
993 type: "turn_item.updated",
994 driver: CursorAgentSdk.CURSOR_PROVIDER,
995 turnItem: {
996 id: turnItemId,
997 threadId: context.input.threadId,
998 runId: context.input.runId,
999 nodeId,
1000 providerThreadId: context.input.providerThread.id,
1001 providerTurnId: context.providerTurnId,
1002 nativeItemRef,
1003 parentItemId: null,
1004 ordinal,
1005 status: completed ? "completed" : "running",
1006 title: null,
1007 startedAt: segment.startedAt,
1008 completedAt: completed ? now : null,
1009 updatedAt: now,
1010 type: "assistant_message",
1011 messageId,
1012 text: segment.text,
1013 streaming: !completed,
1014 },
1015 });
1016 });
1017
1018 const emitReasoning = Effect.fnUntraced(function* (
1019 context: ActiveCursorTurn,
1020 completed: boolean,
1021 ) {
1022 const segment = context.reasoning.current;
1023 if (segment === null || segment.text.length === 0) {
1024 return;
1025 }
1026 const now = yield* DateTime.now;
1027 const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId);
1028 const nodeId = idAllocator.derive.nodeFromProviderItem({
1029 driver: CursorAgentSdk.CURSOR_PROVIDER,
1030 nativeItemId: segment.nativeItemId,
1031 });
1032 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1033 driver: CursorAgentSdk.CURSOR_PROVIDER,
1034 nativeItemId: segment.nativeItemId,
1035 });
1036 const nativeItemRef = {
1037 driver: CursorAgentSdk.CURSOR_PROVIDER,
1038 nativeId: segment.nativeItemId,
1039 strength: "weak" as const,
1040 };
1041 yield* emitProviderEvent({
1042 type: "node.updated",
1043 driver: CursorAgentSdk.CURSOR_PROVIDER,
1044 node: {
1045 id: nodeId,
1046 threadId: context.input.threadId,
1047 runId: context.input.runId,
1048 parentNodeId: context.input.rootNodeId,
1049 rootNodeId: context.input.rootNodeId,
1050 kind: "reasoning",
1051 status: completed ? "completed" : "running",
1052 countsForRun: false,
1053 providerThreadId: context.input.providerThread.id,
1054 providerTurnId: context.providerTurnId,
1055 nativeItemRef,
1056 runtimeRequestId: null,
1057 checkpointScopeId: null,
1058 startedAt: segment.startedAt,
1059 completedAt: completed ? now : null,
1060 },
1061 });
1062 yield* emitProviderEvent({
1063 type: "turn_item.updated",
1064 driver: CursorAgentSdk.CURSOR_PROVIDER,
1065 turnItem: {
1066 id: turnItemId,
1067 threadId: context.input.threadId,
1068 runId: context.input.runId,
1069 nodeId,
1070 providerThreadId: context.input.providerThread.id,
1071 providerTurnId: context.providerTurnId,
1072 nativeItemRef,
1073 parentItemId: null,
1074 ordinal,
1075 status: completed ? "completed" : "running",
1076 title: null,
1077 startedAt: segment.startedAt,
1078 completedAt: completed ? now : null,
1079 updatedAt: now,
1080 type: "reasoning",
1081 text: segment.text,
1082 streaming: !completed,
1083 },
1084 });
1085 });
1086
1087 const appendTextSegment = Effect.fnUntraced(function* (input: {
1088 readonly context: ActiveCursorTurn;
1089 readonly stream: ActiveCursorTextStream;
1090 readonly kind: "assistant" | "reasoning";
1091 readonly text: string;
1092 }) {
1093 if (input.stream.current === null) {
1094 input.stream.nextSegment += 1;
1095 input.stream.current = {
1096 nativeItemId: `${input.kind}:${input.context.run.runId}:${input.stream.nextSegment}`,
1097 startedAt: yield* DateTime.now,
1098 text: "",
1099 };
1100 }
1101 input.stream.current.text += input.text;
1102 });
1103
1104 const completeAssistant = Effect.fnUntraced(function* (context: ActiveCursorTurn) {
1105 if (context.assistant.current === null) {
1106 return;
1107 }
1108 yield* emitAssistant(context, true);
1109 context.assistant.current = null;
1110 });
1111
1112 const completeReasoning = Effect.fnUntraced(function* (context: ActiveCursorTurn) {
1113 if (context.reasoning.current === null) {
1114 return;
1115 }
1116 yield* emitReasoning(context, true);
1117 context.reasoning.current = null;
1118 });
1119
1120 const emitToolArtifacts = Effect.fnUntraced(function* (input: {
1121 readonly active: ActiveCursorToolCall;
1122 readonly completed: boolean;
1123 /** Terminal status for a tool the turn ended before Cursor completed it. */
1124 readonly unfinishedStatus?: "interrupted" | "failed" | "cancelled";
1125 }) {
1126 const { active } = input;
1127 const toolCall = active.toolCall;
1128 const now = yield* DateTime.now;
1129 const failed = input.completed && cursorToolFailed(toolCall);
1130 const status = !input.completed
1131 ? "running"
1132 : (input.unfinishedStatus ?? (failed ? "failed" : "completed"));
1133 const nodeId = idAllocator.derive.nodeFromProviderItem({
1134 driver: CursorAgentSdk.CURSOR_PROVIDER,
1135 nativeItemId: active.callId,
1136 });
1137 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1138 driver: CursorAgentSdk.CURSOR_PROVIDER,
1139 nativeItemId: active.callId,
1140 });
1141 const nativeItemRef = {
1142 driver: CursorAgentSdk.CURSOR_PROVIDER,
1143 nativeId: active.callId,
1144 strength: "strong" as const,
1145 };
1146 const node: OrchestrationV2ExecutionNode = {
1147 id: nodeId,
1148 threadId: active.target.threadId,
1149 runId: active.target.runId,
1150 parentNodeId: active.target.parentNodeId,
1151 rootNodeId: active.target.rootNodeId,
1152 kind: "tool_call",
1153 status,
1154 countsForRun: false,
1155 providerThreadId: active.target.providerThreadId,
1156 providerTurnId: active.target.providerTurnId,
1157 nativeItemRef,
1158 runtimeRequestId: null,
1159 checkpointScopeId: null,
1160 startedAt: active.startedAt,
1161 completedAt: input.completed ? now : null,
1162 };
1163 const base = {
1164 id: turnItemId,
1165 threadId: active.target.threadId,
1166 runId: active.target.runId,
1167 nodeId,
1168 providerThreadId: active.target.providerThreadId,
1169 providerTurnId: active.target.providerTurnId,
1170 nativeItemRef,
1171 parentItemId: null,
1172 ordinal: active.ordinal,
1173 status,
1174 title: null,
1175 startedAt: active.startedAt,
1176 completedAt: input.completed ? now : null,
1177 updatedAt: now,
1178 } satisfies Pick<
1179 OrchestrationV2TurnItem,
1180 | "id"
1181 | "threadId"
1182 | "runId"
1183 | "nodeId"
1184 | "providerThreadId"
1185 | "providerTurnId"
1186 | "nativeItemRef"
1187 | "parentItemId"
1188 | "ordinal"
1189 | "status"
1190 | "title"
1191 | "startedAt"
1192 | "completedAt"
1193 | "updatedAt"
1194 >;
1195 const outputText = cursorToolOutputText(toolCall) || active.streamedOutput;
1196 let turnItem: OrchestrationV2TurnItem;
1197 switch (toolCall.type) {
1198 case "shell":
1199 turnItem = {
1200 ...base,
1201 type: "command_execution",
1202 input: toolCall.args.command,
1203 ...(outputText.length === 0 ? {} : { output: outputText }),
1204 ...(toolCall.result?.status === "success"
1205 ? { exitCode: toolCall.result.value.exitCode }
1206 : {}),
1207 };
1208 break;
1209 case "write":
1210 case "delete":
1211 case "edit":
1212 case "generateImage":
1213 turnItem = {
1214 ...base,
1215 type: "file_change",
1216 fileName: cursorToolFileName(toolCall),
1217 ...(toolCall.type === "edit" &&
1218 toolCall.result?.status === "success" &&
1219 toolCall.result.value.linesAdded !== undefined
1220 ? { additions: toolCall.result.value.linesAdded }
1221 : {}),
1222 ...(toolCall.type === "edit" &&
1223 toolCall.result?.status === "success" &&
1224 toolCall.result.value.linesRemoved !== undefined
1225 ? { deletions: toolCall.result.value.linesRemoved }
1226 : {}),
1227 ...(toolCall.type === "edit" &&
1228 toolCall.result?.status === "success" &&
1229 toolCall.result.value.diffString !== undefined
1230 ? { diffStr: toolCall.result.value.diffString }
1231 : // A failed change keeps its error where the diff would be.
1232 toolCall.result?.status === "error" && outputText.trim().length > 0
1233 ? { diffStr: outputText }
1234 : {}),
1235 ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}),
1236 };
1237 break;
1238 case "read":
1239 turnItem = {
1240 ...base,
1241 title: formatReadToolLabel(toolCall.args.path),
1242 type: "dynamic_tool",
1243 toolName: "Read",
1244 input: toolCall.args,
1245 ...(cursorToolOutput(toolCall) === undefined
1246 ? {}
1247 : { output: cursorToolOutput(toolCall) }),
1248 };
1249 break;
1250 case "glob":
1251 case "grep":
1252 case "ls":
1253 case "readLints":
1254 case "semSearch": {
1255 const pattern = cursorToolSearchPattern(toolCall);
1256 const searchPath =
1257 toolCall.type === "grep"
1258 ? toolCall.args.path
1259 : toolCall.type === "glob"
1260 ? toolCall.args.targetDirectory
1261 : toolCall.type === "semSearch"
1262 ? toolCall.args.targetDirectories?.join(", ")
1263 : pattern;
1264 // A failed search keeps its error as one row under the searched path.
1265 const results =
1266 toolCall.result?.status === "error" && outputText.trim().length > 0
1267 ? [{ fileName: searchPath?.trim() || ".", preview: outputText }]
1268 : cursorToolSearchResults(toolCall, path);
1269 turnItem = {
1270 ...base,
1271 title:
1272 formatSearchToolLabel({
1273 input: toolCall.args,
1274 ...(pattern === undefined ? {} : { pattern }),
1275 }) ?? null,
1276 type: "file_search",
1277 ...(pattern === undefined ? {} : { pattern }),
1278 ...(results.length === 0 ? {} : { results: [...results] }),
1279 };
1280 break;
1281 }
1282 default:
1283 turnItem = {
1284 ...base,
1285 type: "dynamic_tool",
1286 ...(toolCall.type === "mcp"
1287 ? mcpToolPresentation({
1288 serverName: toolCall.args.providerIdentifier,
1289 toolName: toolCall.args.toolName,
1290 })
1291 : {}),
1292 toolName: cursorToolName(toolCall),
1293 input: toolCall.args,
1294 ...(cursorToolOutput(toolCall) === undefined
1295 ? {}
1296 : { output: cursorToolOutput(toolCall) }),
1297 };
1298 }
1299 yield* emitProviderEvent({
1300 type: "node.updated",
1301 driver: CursorAgentSdk.CURSOR_PROVIDER,
1302 node,
1303 });
1304 yield* emitProviderEvent({
1305 type: "turn_item.updated",
1306 driver: CursorAgentSdk.CURSOR_PROVIDER,
1307 turnItem,
1308 });
1309 });
1310
1311 const ensureToolStarted = Effect.fnUntraced(function* (
1312 context: ActiveCursorTurn,
1313 callId: string,
1314 toolCall: ToolCall,
1315 ) {
1316 const existing = context.tools.get(callId);
1317 if (existing !== undefined) {
1318 existing.toolCall = toolCall;
1319 return existing;
1320 }
1321 const startedAt = yield* DateTime.now;
1322 const active: ActiveCursorToolCall = {
1323 callId,
1324 toolCall,
1325 target: parentTarget(context),
1326 ordinal: yield* resolveItemOrdinal(context, callId),
1327 startedAt,
1328 streamedOutput: "",
1329 };
1330 context.tools.set(callId, active);
1331 yield* emitToolArtifacts({ active, completed: false });
1332 return active;
1333 });
1334
1335 const emitPlanArtifacts = Effect.fnUntraced(function* (input: {
1336 readonly context: ActiveCursorTurn;
1337 readonly callId: string;
1338 readonly markdown: string;
1339 readonly completed: boolean;
1340 readonly failed: boolean;
1341 }) {
1342 const nativeItemId = `plan:${input.callId}`;
1343 const now = yield* DateTime.now;
1344 const planId = yield* resolvePlanId(input.context, nativeItemId);
1345 const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId);
1346 const nodeId = idAllocator.derive.nodeFromProviderItem({
1347 driver: CursorAgentSdk.CURSOR_PROVIDER,
1348 nativeItemId,
1349 });
1350 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1351 driver: CursorAgentSdk.CURSOR_PROVIDER,
1352 nativeItemId,
1353 });
1354 const status = input.failed ? "failed" : input.completed ? "completed" : "running";
1355 const nativeItemRef = {
1356 driver: CursorAgentSdk.CURSOR_PROVIDER,
1357 nativeId: input.callId,
1358 strength: "strong" as const,
1359 };
1360 const plan: OrchestrationV2PlanArtifact = {
1361 id: planId,
1362 threadId: input.context.input.threadId,
1363 runId: input.context.input.runId,
1364 nodeId,
1365 kind: "proposed_plan",
1366 status: input.completed ? "active" : "draft",
1367 markdown: input.markdown,
1368 };
1369 yield* emitProviderEvent({
1370 type: "node.updated",
1371 driver: CursorAgentSdk.CURSOR_PROVIDER,
1372 node: {
1373 id: nodeId,
1374 threadId: input.context.input.threadId,
1375 runId: input.context.input.runId,
1376 parentNodeId: input.context.input.rootNodeId,
1377 rootNodeId: input.context.input.rootNodeId,
1378 kind: "plan",
1379 status,
1380 countsForRun: false,
1381 providerThreadId: input.context.input.providerThread.id,
1382 providerTurnId: input.context.providerTurnId,
1383 nativeItemRef,
1384 runtimeRequestId: null,
1385 checkpointScopeId: null,
1386 startedAt: input.context.startedAt,
1387 completedAt: input.completed ? now : null,
1388 },
1389 });
1390 yield* emitProviderEvent({
1391 type: "plan.updated",
1392 driver: CursorAgentSdk.CURSOR_PROVIDER,
1393 plan,
1394 });
1395 yield* emitProviderEvent({
1396 type: "turn_item.updated",
1397 driver: CursorAgentSdk.CURSOR_PROVIDER,
1398 turnItem: {
1399 id: turnItemId,
1400 threadId: input.context.input.threadId,
1401 runId: input.context.input.runId,
1402 nodeId,
1403 providerThreadId: input.context.input.providerThread.id,
1404 providerTurnId: input.context.providerTurnId,
1405 nativeItemRef,
1406 parentItemId: null,
1407 ordinal,
1408 status,
1409 title: null,
1410 startedAt: input.context.startedAt,
1411 completedAt: input.completed ? now : null,
1412 updatedAt: now,
1413 type: "proposed_plan",
1414 planId,
1415 markdown: input.markdown,
1416 streaming: !input.completed,
1417 },
1418 });
1419 });
1420
1421 const emitTodoArtifacts = Effect.fnUntraced(function* (input: {
1422 readonly context: ActiveCursorTurn;
1423 readonly callId: string;
1424 readonly toolCall: Extract<ToolCall, { readonly type: "updateTodos" }>;
1425 readonly completed: boolean;
1426 readonly failed: boolean;
1427 }) {
1428 const nativeItemId = `todos:${input.callId}`;
1429 const now = yield* DateTime.now;
1430 const planId = yield* resolvePlanId(input.context, nativeItemId);
1431 const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId);
1432 const nodeId = idAllocator.derive.nodeFromProviderItem({
1433 driver: CursorAgentSdk.CURSOR_PROVIDER,
1434 nativeItemId,
1435 });
1436 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1437 driver: CursorAgentSdk.CURSOR_PROVIDER,
1438 nativeItemId,
1439 });
1440 const status = input.failed ? "failed" : input.completed ? "completed" : "running";
1441 const steps = cursorTodoSteps(input.toolCall);
1442 const nativeItemRef = {
1443 driver: CursorAgentSdk.CURSOR_PROVIDER,
1444 nativeId: input.callId,
1445 strength: "strong" as const,
1446 };
1447 yield* emitProviderEvent({
1448 type: "node.updated",
1449 driver: CursorAgentSdk.CURSOR_PROVIDER,
1450 node: {
1451 id: nodeId,
1452 threadId: input.context.input.threadId,
1453 runId: input.context.input.runId,
1454 parentNodeId: input.context.input.rootNodeId,
1455 rootNodeId: input.context.input.rootNodeId,
1456 kind: "todo_list",
1457 status,
1458 countsForRun: false,
1459 providerThreadId: input.context.input.providerThread.id,
1460 providerTurnId: input.context.providerTurnId,
1461 nativeItemRef,
1462 runtimeRequestId: null,
1463 checkpointScopeId: null,
1464 startedAt: input.context.startedAt,
1465 completedAt: input.completed ? now : null,
1466 },
1467 });
1468 yield* emitProviderEvent({
1469 type: "plan.updated",
1470 driver: CursorAgentSdk.CURSOR_PROVIDER,
1471 plan: {
1472 id: planId,
1473 threadId: input.context.input.threadId,
1474 runId: input.context.input.runId,
1475 nodeId,
1476 kind: "todo_list",
1477 status: input.completed ? "active" : "draft",
1478 steps: [...steps],
1479 },
1480 });
1481 yield* emitProviderEvent({
1482 type: "turn_item.updated",
1483 driver: CursorAgentSdk.CURSOR_PROVIDER,
1484 turnItem: {
1485 id: turnItemId,
1486 threadId: input.context.input.threadId,
1487 runId: input.context.input.runId,
1488 nodeId,
1489 providerThreadId: input.context.input.providerThread.id,
1490 providerTurnId: input.context.providerTurnId,
1491 nativeItemRef,
1492 parentItemId: null,
1493 ordinal,
1494 status,
1495 title: null,
1496 startedAt: input.context.startedAt,
1497 completedAt: input.completed ? now : null,
1498 updatedAt: now,
1499 type: "todo_list",
1500 planId,
1501 steps: [...steps],
1502 },
1503 });
1504 });
1505
1506 const emitSubagent = Effect.fnUntraced(function* (input: {
1507 readonly context: ActiveCursorTurn;
1508 readonly callId: string;
1509 readonly toolCall: Extract<ToolCall, { readonly type: "task" }>;
1510 readonly completed: boolean;
1511 readonly status?: OrchestrationV2Subagent["status"];
1512 }) {
1513 const args = input.toolCall.args;
1514 const result =
1515 input.toolCall.result?.status === "success" ? input.toolCall.result.value : undefined;
1516 const existing = input.context.subagents.get(input.callId);
1517 if (
1518 existing !== undefined &&
1519 !isOrchestrationV2WorkActive(existing.task.status) &&
1520 !input.completed
1521 )
1522 return;
1523 const now = yield* DateTime.now;
1524 const status: OrchestrationV2Subagent["status"] =
1525 input.status ??
1526 (input.completed
1527 ? cursorToolFailed(input.toolCall)
1528 ? "failed"
1529 : "completed"
1530 : "running");
1531 const resultText = [
1532 ...assistantTextsFromConversationSteps(result?.conversationSteps ?? []),
1533 ...(result?.resultSuffix === undefined ? [] : [result.resultSuffix]),
1534 ]
1535 .filter((part) => part.trim().length > 0)
1536 .join("\n");
1537 const nativeItemId = input.callId;
1538 const nodeId =
1539 existing?.task.id ??
1540 idAllocator.derive.nodeFromProviderItem({
1541 driver: CursorAgentSdk.CURSOR_PROVIDER,
1542 nativeItemId,
1543 });
1544 const childRootNodeId =
1545 existing?.childRootNodeId ??
1546 idAllocator.derive.nodeFromProviderItem({
1547 driver: CursorAgentSdk.CURSOR_PROVIDER,
1548 nativeItemId: `${nativeItemId}:child-root`,
1549 });
1550 const childThreadId =
1551 existing?.childThreadId ??
1552 idAllocator.derive.threadFromProviderThread({
1553 driver: CursorAgentSdk.CURSOR_PROVIDER,
1554 nativeThreadId: `${input.context.run.runId}:task:${input.callId}`,
1555 });
1556 const task: OrchestrationV2Subagent = {
1557 ...(existing?.task ?? {
1558 id: nodeId,
1559 threadId: input.context.input.threadId,
1560 runId: input.context.input.runId,
1561 parentNodeId: input.context.input.rootNodeId,
1562 origin: "provider_native" as const,
1563 createdBy: "agent" as const,
1564 driver: CursorAgentSdk.CURSOR_PROVIDER,
1565 providerInstanceId: input.context.input.modelSelection.instanceId,
1566 providerThreadId: null,
1567 childThreadId,
1568 nativeTaskRef: {
1569 driver: CursorAgentSdk.CURSOR_PROVIDER,
1570 nativeId: input.callId,
1571 strength: "strong" as const,
1572 },
1573 prompt: args.prompt,
1574 title: args.description,
1575 result: null,
1576 startedAt: now,
1577 }),
1578 model: args.model?.trim() || existing?.task.model || null,
1579 nativeTaskRef: {
1580 driver: CursorAgentSdk.CURSOR_PROVIDER,
1581 nativeId: input.callId,
1582 strength: "strong" as const,
1583 },
1584 status,
1585 result: resultText.length === 0 ? (existing?.task.result ?? null) : resultText,
1586 completedAt: input.completed ? (existing?.task.completedAt ?? now) : null,
1587 updatedAt: now,
1588 };
1589 const subagent: ActiveCursorSubagent = {
1590 task,
1591 toolCall: input.toolCall,
1592 callId: input.callId,
1593 childThreadId,
1594 childRootNodeId,
1595 turnItemId:
1596 existing?.turnItemId ??
1597 idAllocator.derive.turnItemFromProviderItem({
1598 driver: CursorAgentSdk.CURSOR_PROVIDER,
1599 nativeItemId,
1600 }),
1601 turnItemOrdinal:
1602 existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(input.context, nativeItemId)),
1603 nextChildOrdinal: existing?.nextChildOrdinal ?? 100,
1604 resultProjected: existing?.resultProjected ?? false,
1605 };
1606 input.context.subagents.set(input.callId, subagent);
1607
1608 if (existing === undefined) {
1609 yield* emitProviderEvent({
1610 type: "app_thread.created",
1611 driver: CursorAgentSdk.CURSOR_PROVIDER,
1612 appThread: makeSubagentChildThread({
1613 parentThread: input.context.input.appThread,
1614 childThreadId,
1615 parentNodeId: nodeId,
1616 activeProviderThreadId: null,
1617 providerInstanceId: input.context.input.modelSelection.instanceId,
1618 modelSelection: input.context.input.modelSelection,
1619 title: subagentThreadTitle({
1620 parentTitle: input.context.input.appThread.title,
1621 title: args.description,
1622 prompt: args.prompt,
1623 ordinal: input.context.subagents.size,
1624 }),
1625 now,
1626 createdBy: "agent",
1627 creationSource: "provider",
1628 }),
1629 });
1630 const promptNativeId = `${nativeItemId}:prompt`;
1631 const promptArtifacts = makeSubagentConversationArtifacts({
1632 senderThreadId: input.context.input.threadId,
1633 messageId: idAllocator.derive.messageFromProviderItem({
1634 driver: CursorAgentSdk.CURSOR_PROVIDER,
1635 nativeItemId: promptNativeId,
1636 }),
1637 turnItemId: idAllocator.derive.turnItemFromProviderItem({
1638 driver: CursorAgentSdk.CURSOR_PROVIDER,
1639 nativeItemId: promptNativeId,
1640 }),
1641 threadId: childThreadId,
1642 rootNodeId: childRootNodeId,
1643 providerThreadId: null,
1644 providerTurnId: null,
1645 nativeItemRef: {
1646 driver: CursorAgentSdk.CURSOR_PROVIDER,
1647 nativeId: promptNativeId,
1648 strength: "weak",
1649 },
1650 role: "user",
1651 text: args.prompt,
1652 ordinal: 100,
1653 now,
1654 });
1655 yield* emitProviderEvent({
1656 type: "message.updated",
1657 driver: CursorAgentSdk.CURSOR_PROVIDER,
1658 message: promptArtifacts.message,
1659 });
1660 yield* emitProviderEvent({
1661 type: "turn_item.updated",
1662 driver: CursorAgentSdk.CURSOR_PROVIDER,
1663 turnItem: promptArtifacts.turnItem,
1664 });
1665 }
1666
1667 yield* emitProviderEvent({
1668 type: "node.updated",
1669 driver: CursorAgentSdk.CURSOR_PROVIDER,
1670 node: {
1671 id: nodeId,
1672 threadId: input.context.input.threadId,
1673 runId: input.context.input.runId,
1674 parentNodeId: input.context.input.rootNodeId,
1675 rootNodeId: input.context.input.rootNodeId,
1676 kind: "subagent",
1677 status,
1678 countsForRun: false,
1679 providerThreadId: input.context.input.providerThread.id,
1680 providerTurnId: input.context.providerTurnId,
1681 nativeItemRef: task.nativeTaskRef,
1682 runtimeRequestId: null,
1683 checkpointScopeId: null,
1684 startedAt: task.startedAt,
1685 completedAt: task.completedAt,
1686 },
1687 });
1688 yield* emitProviderEvent({
1689 type: "node.updated",
1690 driver: CursorAgentSdk.CURSOR_PROVIDER,
1691 node: {
1692 id: childRootNodeId,
1693 threadId: childThreadId,
1694 runId: null,
1695 parentNodeId: null,
1696 rootNodeId: childRootNodeId,
1697 kind: "root_turn",
1698 status,
1699 countsForRun: false,
1700 providerThreadId: null,
1701 providerTurnId: null,
1702 nativeItemRef: task.nativeTaskRef,
1703 runtimeRequestId: null,
1704 checkpointScopeId: null,
1705 startedAt: task.startedAt,
1706 completedAt: task.completedAt,
1707 },
1708 });
1709 yield* emitProviderEvent({
1710 type: "subagent.updated",
1711 driver: CursorAgentSdk.CURSOR_PROVIDER,
1712 subagent: task,
1713 });
1714 yield* emitProviderEvent({
1715 type: "turn_item.updated",
1716 driver: CursorAgentSdk.CURSOR_PROVIDER,
1717 turnItem: {
1718 id: subagent.turnItemId,
1719 threadId: input.context.input.threadId,
1720 runId: input.context.input.runId,
1721 nodeId,
1722 providerThreadId: input.context.input.providerThread.id,
1723 providerTurnId: input.context.providerTurnId,
1724 nativeItemRef: task.nativeTaskRef,
1725 parentItemId: null,
1726 ordinal: subagent.turnItemOrdinal,
1727 status,
1728 title: task.title,
1729 startedAt: task.startedAt,
1730 completedAt: task.completedAt,
1731 updatedAt: now,
1732 type: "subagent",
1733 subagentId: task.id,
1734 origin: task.origin,
1735 driver: task.driver,
1736 providerInstanceId: task.providerInstanceId,
1737 childThreadId,
1738 prompt: task.prompt,
1739 result: task.result,
1740 },
1741 });
1742
1743 if (input.completed && !subagent.resultProjected) {
1744 for (const [index, nestedTool] of toolCallsFromConversationSteps(
1745 result?.conversationSteps ?? [],
1746 ).entries()) {
1747 const callId = `${nativeItemId}:child-tool:${nestedTool.callId || index + 1}`;
1748 const startedAt = task.startedAt ?? now;
1749 const active: ActiveCursorToolCall = {
1750 callId,
1751 toolCall: nestedTool.toolCall,
1752 target: {
1753 threadId: childThreadId,
1754 runId: null,
1755 rootNodeId: childRootNodeId,
1756 parentNodeId: childRootNodeId,
1757 providerThreadId: null,
1758 providerTurnId: null,
1759 },
1760 ordinal: ++subagent.nextChildOrdinal,
1761 startedAt,
1762 streamedOutput: "",
1763 };
1764 yield* emitToolArtifacts({ active, completed: true });
1765 }
1766 if (task.result !== null && task.result.length > 0) {
1767 const resultNativeId = `${nativeItemId}:result`;
1768 const resultArtifacts = makeSubagentConversationArtifacts({
1769 messageId: idAllocator.derive.messageFromProviderItem({
1770 driver: CursorAgentSdk.CURSOR_PROVIDER,
1771 nativeItemId: resultNativeId,
1772 }),
1773 turnItemId: idAllocator.derive.turnItemFromProviderItem({
1774 driver: CursorAgentSdk.CURSOR_PROVIDER,
1775 nativeItemId: resultNativeId,
1776 }),
1777 threadId: childThreadId,
1778 rootNodeId: childRootNodeId,
1779 providerThreadId: null,
1780 providerTurnId: null,
1781 nativeItemRef: {
1782 driver: CursorAgentSdk.CURSOR_PROVIDER,
1783 nativeId: resultNativeId,
1784 strength: "weak",
1785 },
1786 role: "assistant",
1787 text: task.result,
1788 ordinal: ++subagent.nextChildOrdinal,
1789 now,
1790 });
1791 yield* emitProviderEvent({
1792 type: "message.updated",
1793 driver: CursorAgentSdk.CURSOR_PROVIDER,
1794 message: resultArtifacts.message,
1795 });
1796 yield* emitProviderEvent({
1797 type: "turn_item.updated",
1798 driver: CursorAgentSdk.CURSOR_PROVIDER,
1799 turnItem: resultArtifacts.turnItem,
1800 });
1801 }
1802 subagent.resultProjected = true;
1803 }
1804 });
1805
1806 const handleToolUpdate = Effect.fnUntraced(function* (
1807 context: ActiveCursorTurn,
1808 update: Extract<
1809 InteractionUpdate,
1810 {
1811 readonly type: "tool-call-started" | "partial-tool-call" | "tool-call-completed";
1812 }
1813 >,
1814 ) {
1815 const completed = update.type === "tool-call-completed";
1816 const toolCall = update.toolCall;
1817 switch (toolCall.type) {
1818 case "createPlan":
1819 yield* emitPlanArtifacts({
1820 context,
1821 callId: update.callId,
1822 markdown: toolCall.args.plan,
1823 completed,
1824 failed: completed && cursorToolFailed(toolCall),
1825 });
1826 return;
1827 case "updateTodos":
1828 yield* emitTodoArtifacts({
1829 context,
1830 callId: update.callId,
1831 toolCall,
1832 completed,
1833 failed: completed && cursorToolFailed(toolCall),
1834 });
1835 return;
1836 case "task":
1837 if (update.type === "partial-tool-call") {
1838 return;
1839 }
1840 yield* emitSubagent({
1841 context,
1842 callId: update.callId,
1843 toolCall,
1844 completed,
1845 });
1846 return;
1847 default: {
1848 const active = yield* ensureToolStarted(context, update.callId, toolCall);
1849 active.toolCall = toolCall;
1850 if (update.type !== "tool-call-started") {
1851 yield* emitToolArtifacts({ active, completed });
1852 }
1853 if (completed) {
1854 context.tools.delete(update.callId);
1855 }
1856 }
1857 }
1858 });
1859
1860 const shellOutputText = (event: Record<string, unknown>): string => {
1861 const candidates = [
1862 event.text,
1863 event.output,
1864 event.data,
1865 event.stdout,
1866 event.stderr,
1867 event.chunk,
1868 ];
1869 return candidates.filter((value): value is string => typeof value === "string").join("");
1870 };
1871
1872 const handleInteractionUpdate = Effect.fnUntraced(function* (
1873 context: ActiveCursorTurn,
1874 update: InteractionUpdate,
1875 ) {
1876 if (context.finalized) {
1877 return;
1878 }
1879 switch (update.type) {
1880 case "text-delta":
1881 context.assistantReply.push(update.text);
1882 yield* completeReasoning(context);
1883 yield* appendTextSegment({
1884 context,
1885 stream: context.assistant,
1886 kind: "assistant",
1887 text: update.text,
1888 });
1889 yield* emitAssistant(context, false);
1890 return;
1891 case "thinking-delta":
1892 yield* completeAssistant(context);
1893 yield* appendTextSegment({
1894 context,
1895 stream: context.reasoning,
1896 kind: "reasoning",
1897 text: update.text,
1898 });
1899 yield* emitReasoning(context, false);
1900 return;
1901 case "thinking-completed":
1902 yield* completeReasoning(context);
1903 return;
1904 case "tool-call-started":
1905 case "partial-tool-call":
1906 case "tool-call-completed":
1907 yield* completeAssistant(context);
1908 yield* completeReasoning(context);
1909 yield* handleToolUpdate(context, update);
1910 return;
1911 case "step-completed":
1912 case "turn-ended":
1913 yield* completeAssistant(context);
1914 yield* completeReasoning(context);
1915 return;
1916 case "shell-output-delta": {
1917 const shell = Array.from(context.tools.values())
1918 .toReversed()
1919 .find((candidate) => candidate.toolCall.type === "shell");
1920 if (shell === undefined) {
1921 return;
1922 }
1923 shell.streamedOutput += shellOutputText(update.event);
1924 yield* emitToolArtifacts({ active: shell, completed: false });
1925 return;
1926 }
1927 default:
1928 return;
1929 }
1930 });
1931
1932 const providerTurnPayload = (input: {
1933 readonly context: ActiveCursorTurn;
1934 readonly status: OrchestrationV2ProviderTurn["status"];
1935 readonly completedAt: DateTime.Utc | null;
1936 }): OrchestrationV2ProviderTurn => ({
1937 id: input.context.providerTurnId,
1938 providerThreadId: input.context.input.providerThread.id,
1939 nodeId: input.context.input.rootNodeId,
1940 runAttemptId: input.context.input.attemptId,
1941 nativeTurnRef: {
1942 driver: CursorAgentSdk.CURSOR_PROVIDER,
1943 nativeId: input.context.run.runId,
1944 strength: "strong",
1945 },
1946 ordinal: input.context.input.providerTurnOrdinal,
1947 status: input.status,
1948 startedAt: input.context.startedAt,
1949 completedAt: input.completedAt,
1950 });
1951
1952 const finalizeTurn = Effect.fnUntraced(function* (input: {
1953 readonly context: ActiveCursorTurn;
1954 readonly status: Extract<
1955 OrchestrationV2ProviderTurn["status"],
1956 "completed" | "interrupted" | "failed" | "cancelled"
1957 >;
1958 readonly failure?: OrchestrationV2ProviderFailure;
1959 readonly threadDisposition?: "reusable" | "broken";
1960 }) {
1961 if (input.context.finalized) {
1962 return;
1963 }
1964 input.context.finalized = true;
1965 const completedAt = yield* DateTime.now;
1966 // Tools still here never got a tool-call-completed. A stopped or
1967 // failed turn cut them short, so they end with the turn's status.
1968 for (const tool of input.context.tools.values()) {
1969 yield* emitToolArtifacts({
1970 active: tool,
1971 completed: true,
1972 ...(input.status === "completed" ? {} : { unfinishedStatus: input.status }),
1973 });
1974 }
1975 input.context.tools.clear();
1976 // This interaction stops delivering updates at finalization. Tasks
1977 // without a completion have an unknown outcome. Background launch
1978 // acknowledgements have already settled their rows.
1979 for (const subagent of input.context.subagents.values()) {
1980 if (!isOrchestrationV2WorkActive(subagent.task.status)) continue;
1981 yield* emitSubagent({
1982 context: input.context,
1983 callId: subagent.callId,
1984 toolCall: subagent.toolCall,
1985 completed: true,
1986 status: input.status === "completed" ? "idle" : input.status,
1987 });
1988 }
1989 yield* completeReasoning(input.context);
1990 yield* completeAssistant(input.context);
1991 yield* emitProviderEvent({
1992 type: "provider_turn.updated",
1993 driver: CursorAgentSdk.CURSOR_PROVIDER,
1994 providerTurn: providerTurnPayload({
1995 context: input.context,
1996 status: input.status,
1997 completedAt,
1998 }),
1999 });
2000 yield* emitProviderEvent({
2001 type: "provider_thread.updated",
2002 driver: CursorAgentSdk.CURSOR_PROVIDER,
2003 providerThread: {
2004 ...input.context.input.providerThread,
2005 providerSessionId: session.id,
2006 status: "active",
2007 firstRunOrdinal:
2008 input.context.input.providerThread.firstRunOrdinal ??
2009 input.context.input.runOrdinal,
2010 lastRunOrdinal: input.context.input.runOrdinal,
2011 updatedAt: completedAt,
2012 },
2013 });
2014 const threadDisposition = input.threadDisposition ?? "reusable";
2015 yield* emitProviderEvent(
2016 input.status === "failed"
2017 ? {
2018 type: "turn.terminal",
2019 driver: CursorAgentSdk.CURSOR_PROVIDER,
2020 providerThreadId: input.context.input.providerThread.id,
2021 providerTurnId: input.context.providerTurnId,
2022 runOrdinal: input.context.input.runOrdinal,
2023 failureItemOrdinal: yield* resolveItemOrdinal(
2024 input.context,
2025 `terminal-failure:${input.context.providerTurnId}`,
2026 ),
2027 status: input.status,
2028 failure: input.failure ?? makeProviderFailure({ class: "provider_error" }),
2029 threadDisposition,
2030 }
2031 : {
2032 type: "turn.terminal",
2033 driver: CursorAgentSdk.CURSOR_PROVIDER,
2034 providerThreadId: input.context.input.providerThread.id,
2035 providerTurnId: input.context.providerTurnId,
2036 runOrdinal: input.context.input.runOrdinal,
2037 status: input.status,
2038 failure: null,
2039 threadDisposition,
2040 },
2041 );
2042 yield* Ref.update(activeTurn, (current) =>
2043 current?.providerTurnId === input.context.providerTurnId ? null : current,
2044 );
2045 yield* Deferred.succeed(input.context.completed, undefined);
2046 });
2047
2048 const terminalStatus = (
2049 context: ActiveCursorTurn,
2050 result: RunResult,
2051 ): Extract<
2052 OrchestrationV2ProviderTurn["status"],
2053 "completed" | "interrupted" | "failed" | "cancelled"
2054 > => {
2055 if (context.interrupted) {
2056 return "interrupted";
2057 }
2058 switch (result.status) {
2059 case "finished":
2060 return "completed";
2061 case "cancelled":
2062 return "cancelled";
2063 case "error":
2064 return "failed";
2065 }
2066 };
2067
2068 const openAgent = Effect.fnUntraced(function* (openInput: {
2069 readonly operation: "create" | "resume";
2070 readonly threadId: ThreadId;
2071 readonly modelSelection: ModelSelection;
2072 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
2073 readonly agentId?: string;
2074 }) {
2075 const existing = yield* Ref.get(liveAgent);
2076 if (
2077 existing !== null &&
2078 openInput.operation === "resume" &&
2079 existing.nativeThreadId === openInput.agentId
2080 ) {
2081 return existing;
2082 }
2083 if (existing !== null) {
2084 yield* existing.session.close.pipe(Effect.ignore);
2085 yield* Ref.set(liveAgent, null);
2086 }
2087 const sdkSession = yield* runner.open({
2088 operation: openInput.operation,
2089 ...(openInput.agentId === undefined ? {} : { agentId: openInput.agentId }),
2090 options: makeCursorAgentOptions({
2091 ...(apiKey === undefined ? {} : { apiKey }),
2092 modelSelection: openInput.modelSelection,
2093 runtimePolicy: openInput.runtimePolicy,
2094 threadId: openInput.threadId,
2095 }),
2096 threadId: openInput.threadId,
2097 providerSessionId: input.providerSessionId,
2098 });
2099 const next = {
2100 nativeThreadId: sdkSession.agentId,
2101 session: sdkSession,
2102 } satisfies CursorLiveAgent;
2103 yield* Ref.set(liveAgent, next);
2104 return next;
2105 });
2106
2107 let cursorSkillNames: ReadonlySet<string> | undefined;
2108 const resolveUserMessage = Effect.fnUntraced(function* (
2109 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
2110 ) {
2111 const rawText = turnInput.message.text;
2112 if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) {
2113 return "/compress";
2114 }
2115 if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) {
2116 const skills = yield* discoverCursorSkills(
2117 turnInput.runtimePolicy.cwd ?? undefined,
2118 adapterOptions.environment,
2119 ).pipe(
2120 Effect.provideService(FileSystem.FileSystem, fileSystem),
2121 Effect.provideService(Path.Path, path),
2122 );
2123 cursorSkillNames = new Set(
2124 skills
2125 .filter((skill) => skill.enabled && skill.userInvocable !== false)
2126 .map((skill) => skill.name),
2127 );
2128 }
2129 const userText = t3OrchestrationPromptForFirstRun({
2130 prompt: providerMessageTextWithAttachmentPaths({
2131 text:
2132 cursorSkillNames === undefined
2133 ? rawText
2134 : rewriteCursorSkillMentions(rawText, cursorSkillNames),
2135 attachments: turnInput.message.attachments,
2136 resolveAttachmentPath: (attachment) =>
2137 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
2138 }),
2139 runOrdinal: turnInput.runOrdinal,
2140 hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined,
2141 });
2142 const images = yield* Effect.forEach(
2143 turnInput.message.attachments.filter(isProviderNativeImageAttachment),
2144 (attachment: ChatAttachment) =>
2145 Effect.gen(function* () {
2146 const path = resolveAttachmentPath({
2147 attachmentsDir: serverConfig.attachmentsDir,
2148 attachment,
2149 });
2150 if (path === null) {
2151 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
2152 driver: CursorAgentSdk.CURSOR_PROVIDER,
2153 detail: `Invalid attachment id '${attachment.id}'.`,
2154 });
2155 }
2156 const bytes = yield* fileSystem.readFile(path).pipe(
2157 Effect.mapError(
2158 (cause) =>
2159 new ProviderAdapter.ProviderAdapterProtocolError({
2160 driver: CursorAgentSdk.CURSOR_PROVIDER,
2161 detail: `Failed to read attachment '${attachment.id}'.`,
2162 payload: cause,
2163 }),
2164 ),
2165 );
2166 return {
2167 data: Buffer.from(bytes).toString("base64"),
2168 mimeType: attachment.mimeType,
2169 };
2170 }),
2171 { concurrency: 1 },
2172 );
2173 if (userText.length === 0 && images.length === 0) {
2174 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
2175 driver: CursorAgentSdk.CURSOR_PROVIDER,
2176 detail: "Cursor turn requires non-empty text or attachments.",
2177 });
2178 }
2179 const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`;
2180 return images.length === 0
2181 ? text
2182 : ({
2183 text,
2184 images,
2185 } satisfies SDKUserMessage);
2186 });
2187
2188 const startTurn = Effect.fn("CursorAdapterV2.startTurn")(
2189 function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) {
2190 const current = yield* Ref.get(activeTurn);
2191 if (current !== null) {
2192 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
2193 driver: CursorAgentSdk.CURSOR_PROVIDER,
2194 detail: `Cursor provider turn ${current.providerTurnId} is still active.`,
2195 });
2196 }
2197 const agentId = nativeThreadId(turnInput.providerThread);
2198 const agent = yield* openAgent({
2199 operation: "resume",
2200 agentId,
2201 threadId: turnInput.threadId,
2202 modelSelection: turnInput.modelSelection,
2203 runtimePolicy: turnInput.runtimePolicy,
2204 });
2205 const message = yield* resolveUserMessage(turnInput);
2206 const mcpServers = cursorMcpServers(turnInput.threadId);
2207 const pendingUpdates: Array<InteractionUpdate> = [];
2208 let context: ActiveCursorTurn | null = null;
2209 const sdkRun = yield* agent.session.send({
2210 message,
2211 options: {
2212 model: cursorSdkModelSelection(turnInput.modelSelection),
2213 mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent",
2214 ...(mcpServers === undefined ? {} : { mcpServers }),
2215 },
2216 onDelta: (update) => {
2217 if (context === null) {
2218 return Effect.sync(() => {
2219 pendingUpdates.push(update);
2220 });
2221 }
2222 return handleInteractionUpdate(context, update);
2223 },
2224 });
2225 const startedAt = yield* DateTime.now;
2226 const completed = yield* Deferred.make<void, never>();
2227 const providerTurnId = idAllocator.derive.providerTurn({
2228 driver: CursorAgentSdk.CURSOR_PROVIDER,
2229 nativeTurnId: sdkRun.runId,
2230 });
2231 context = {
2232 input: turnInput,
2233 run: sdkRun,
2234 providerTurnId,
2235 startedAt,
2236 completed,
2237 itemOrdinals: new Map(),
2238 tools: new Map(),
2239 subagents: new Map(),
2240 assistant: {
2241 current: null,
2242 nextSegment: 0,
2243 },
2244 assistantReply: new CursorTransportFailure(),
2245 reasoning: {
2246 current: null,
2247 nextSegment: 0,
2248 },
2249 interrupted: false,
2250 finalized: false,
2251 };
2252 yield* Ref.set(activeTurn, context);
2253 yield* emitProviderEvent({
2254 type: "provider_turn.updated",
2255 driver: CursorAgentSdk.CURSOR_PROVIDER,
2256 providerTurn: providerTurnPayload({
2257 context,
2258 status: "running",
2259 completedAt: null,
2260 }),
2261 });
2262 yield* emitProviderEvent({
2263 type: "provider_thread.updated",
2264 driver: CursorAgentSdk.CURSOR_PROVIDER,
2265 providerThread: {
2266 ...turnInput.providerThread,
2267 providerSessionId: session.id,
2268 status: "active",
2269 updatedAt: startedAt,
2270 },
2271 });
2272 for (const update of pendingUpdates) {
2273 yield* handleInteractionUpdate(context, update);
2274 }
2275
2276 yield* sdkRun.wait.pipe(
2277 Effect.flatMap((result) =>
2278 Effect.gen(function* () {
2279 if (
2280 context !== null &&
2281 context.assistant.nextSegment === 0 &&
2282 result.result !== undefined &&
2283 result.result.length > 0
2284 ) {
2285 context.assistantReply.push(result.result);
2286 yield* appendTextSegment({
2287 context,
2288 stream: context.assistant,
2289 kind: "assistant",
2290 text: result.result,
2291 });
2292 }
2293 if (context !== null) {
2294 const transportFailure = context.assistantReply.failure;
2295 const status =
2296 transportFailure === undefined ? terminalStatus(context, result) : "failed";
2297 yield* finalizeTurn({
2298 context,
2299 status,
2300 ...(status === "failed"
2301 ? {
2302 failure: makeProviderFailure({
2303 cause:
2304 transportFailure ?? (result as { readonly error?: unknown }).error,
2305 class:
2306 transportFailure === undefined
2307 ? "provider_error"
2308 : "transport_error",
2309 }),
2310 }
2311 : {}),
2312 });
2313 }
2314 }),
2315 ),
2316 Effect.catch((cause) =>
2317 Effect.gen(function* () {
2318 if (context !== null) {
2319 yield* finalizeTurn({
2320 context,
2321 status: context.interrupted ? "interrupted" : "failed",
2322 ...(context.interrupted
2323 ? {}
2324 : {
2325 failure: makeProviderFailure({ cause, class: "transport_error" }),
2326 }),
2327 });
2328 }
2329 yield* Effect.logWarning("orchestration-v2.cursor-run-failed", {
2330 providerSessionId: input.providerSessionId,
2331 providerThreadId: turnInput.providerThread.id,
2332 providerTurnId,
2333 cause,
2334 });
2335 }),
2336 ),
2337 Effect.forkIn(sessionScope),
2338 );
2339 },
2340 (effect, turnInput) =>
2341 effect.pipe(
2342 Effect.mapError(
2343 (cause) =>
2344 new ProviderAdapter.ProviderAdapterTurnStartError({
2345 driver: CursorAgentSdk.CURSOR_PROVIDER,
2346 threadId: turnInput.threadId,
2347 providerThreadId: turnInput.providerThread.id,
2348 runId: turnInput.runId,
2349 cause,
2350 }),
2351 ),
2352 ),
2353 );
2354
2355 const closeSession = Effect.fnUntraced(function* () {
2356 const existing = yield* Ref.get(liveAgent);
2357 if (existing !== null) {
2358 yield* existing.session.close.pipe(Effect.ignore);
2359 yield* Ref.set(liveAgent, null);
2360 }
2361 yield* runner.assertComplete.pipe(
2362 Effect.catchCause((cause) =>
2363 Effect.logWarning("orchestration-v2.cursor-runner-incomplete", {
2364 providerSessionId: input.providerSessionId,
2365 cause,
2366 }),
2367 ),
2368 );
2369 });
2370 yield* Effect.addFinalizer(() => closeSession());
2371
2372 const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
2373 instanceId: adapterOptions.instanceId,
2374 driver: CursorAgentSdk.CURSOR_PROVIDER,
2375 providerSessionId: input.providerSessionId,
2376 providerSession: session,
2377 events: Stream.fromEffectRepeat(Queue.take(events)),
2378 ensureThread: Effect.fn("CursorAdapterV2.ensureThread")(
2379 function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) {
2380 const opened = yield* openAgent({
2381 operation: "create",
2382 threadId: threadInput.threadId,
2383 modelSelection: threadInput.modelSelection,
2384 runtimePolicy: threadInput.runtimePolicy,
2385 });
2386 const now = yield* DateTime.now;
2387 return makeProviderThread({
2388 idAllocator,
2389 providerInstanceId: adapterOptions.instanceId,
2390 appThreadId: threadInput.threadId,
2391 providerSessionId: input.providerSessionId,
2392 nativeThreadId: opened.nativeThreadId,
2393 now,
2394 });
2395 },
2396 (effect, threadInput) =>
2397 effect.pipe(
2398 Effect.mapError(
2399 (cause) =>
2400 new ProviderAdapter.ProviderAdapterEnsureThreadError({
2401 driver: CursorAgentSdk.CURSOR_PROVIDER,
2402 threadId: threadInput.threadId,
2403 cause,
2404 }),
2405 ),
2406 ),
2407 ),
2408 resumeThread: Effect.fn("CursorAdapterV2.resumeThread")(
2409 function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) {
2410 const agentId = nativeThreadId(threadInput.providerThread);
2411 yield* openAgent({
2412 operation: "resume",
2413 agentId,
2414 threadId: threadInput.providerThread.appThreadId ?? input.threadId,
2415 modelSelection: input.modelSelection,
2416 runtimePolicy: input.runtimePolicy,
2417 });
2418 const now = yield* DateTime.now;
2419 return {
2420 ...threadInput.providerThread,
2421 providerSessionId: input.providerSessionId,
2422 status: "idle" as const,
2423 updatedAt: now,
2424 };
2425 },
2426 (effect, threadInput) =>
2427 effect.pipe(
2428 Effect.mapError(
2429 (cause) =>
2430 new ProviderAdapter.ProviderAdapterResumeThreadError({
2431 driver: CursorAgentSdk.CURSOR_PROVIDER,
2432 providerSessionId: input.providerSessionId,
2433 providerThreadId: threadInput.providerThread.id,
2434 cause,
2435 }),
2436 ),
2437 ),
2438 ),
2439 startTurn,
2440 compactThread: (turnInput) =>
2441 startTurn({
2442 ...turnInput,
2443 message: { ...turnInput.message, text: "/compress" },
2444 }),
2445 steerTurn: (turnInput) =>
2446 Effect.fail(
2447 new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({
2448 driver: CursorAgentSdk.CURSOR_PROVIDER,
2449 providerThreadId: turnInput.providerThread.id,
2450 }),
2451 ),
2452 interruptTurn: Effect.fn("CursorAdapterV2.interruptTurn")(
2453 function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) {
2454 const context = yield* Ref.get(activeTurn);
2455 // Stop on a settled turn: finalization already ended its tools
2456 // and subagents, so nothing of it is left running to stop.
2457 if (context === null && turnInput.requestRuntimeRestart === true) return;
2458 if (context?.providerTurnId !== turnInput.providerTurnId) {
2459 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
2460 driver: CursorAgentSdk.CURSOR_PROVIDER,
2461 detail: `Cursor provider turn ${turnInput.providerTurnId} is not active.`,
2462 });
2463 }
2464 context.interrupted = true;
2465 yield* context.run.cancel;
2466 const stopped = yield* Deferred.await(context.completed).pipe(
2467 Effect.timeoutOption("10 seconds"),
2468 );
2469 if (Option.isSome(stopped)) {
2470 return;
2471 }
2472 yield* Effect.logWarning("orchestration-v2.cursor-interrupt-timeout", {
2473 providerSessionId: input.providerSessionId,
2474 providerThreadId: turnInput.providerThread.id,
2475 providerTurnId: turnInput.providerTurnId,
2476 });
2477 yield* finalizeTurn({ context, status: "interrupted" });
2478 },
2479 (effect, turnInput) =>
2480 effect.pipe(
2481 Effect.mapError(
2482 (cause) =>
2483 new ProviderAdapter.ProviderAdapterInterruptError({
2484 driver: CursorAgentSdk.CURSOR_PROVIDER,
2485 providerThreadId: turnInput.providerThread.id,
2486 providerTurnId: turnInput.providerTurnId,
2487 cause,
2488 }),
2489 ),
2490 ),
2491 ),
2492 respondToRuntimeRequest: (requestInput) =>
2493 Effect.fail(
2494 new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
2495 driver: CursorAgentSdk.CURSOR_PROVIDER,
2496 requestId: requestInput.requestId,
2497 cause: new ProviderAdapter.ProviderAdapterProtocolError({
2498 driver: CursorAgentSdk.CURSOR_PROVIDER,
2499 detail: "Cursor Agent SDK does not expose interactive approval requests.",
2500 }),
2501 }),
2502 ),
2503 readThreadSnapshot: Effect.fn("CursorAdapterV2.readThreadSnapshot")(
2504 function* (snapshotInput) {
2505 const requestedAgentId = nativeThreadId(snapshotInput.providerThread);
2506 const agent = yield* openAgent({
2507 operation: "resume",
2508 agentId: requestedAgentId,
2509 threadId: snapshotInput.providerThread.appThreadId ?? input.threadId,
2510 modelSelection: input.modelSelection,
2511 runtimePolicy: input.runtimePolicy,
2512 });
2513 const messages = yield* agent.session.listMessages;
2514 const now = yield* DateTime.now;
2515 const threadId = snapshotInput.providerThread.appThreadId ?? input.threadId;
2516 const projectedMessages: Array<OrchestrationV2ConversationMessage> = messages.flatMap(
2517 (message) => {
2518 const text = textFromAgentMessage(message);
2519 if (text.length === 0) {
2520 return [];
2521 }
2522 return [
2523 {
2524 createdBy: message.type === "user" ? "user" : "agent",
2525 creationSource: "provider",
2526 id: idAllocator.derive.messageFromProviderItem({
2527 driver: CursorAgentSdk.CURSOR_PROVIDER,
2528 nativeItemId: message.uuid,
2529 }),
2530 threadId,
2531 runId: null,
2532 nodeId: null,
2533 role: message.type,
2534 text,
2535 attachments: [],
2536 streaming: false,
2537 createdAt: now,
2538 updatedAt: now,
2539 },
2540 ];
2541 },
2542 );
2543 return {
2544 providerThread: {
2545 ...snapshotInput.providerThread,
2546 providerSessionId: input.providerSessionId,
2547 status: "idle" as const,
2548 updatedAt: now,
2549 },
2550 providerTurns: [],
2551 messages: projectedMessages,
2552 runtimeRequests: [],
2553 providerPayload: {
2554 agentId: requestedAgentId,
2555 messages,
2556 },
2557 };
2558 },
2559 (effect, snapshotInput) =>
2560 effect.pipe(
2561 Effect.mapError(
2562 (cause) =>
2563 new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
2564 driver: CursorAgentSdk.CURSOR_PROVIDER,
2565 providerThreadId: snapshotInput.providerThread.id,
2566 cause,
2567 }),
2568 ),
2569 ),
2570 ),
2571 rollbackThread: (rollbackInput) =>
2572 Effect.fail(
2573 new ProviderAdapter.ProviderAdapterRollbackThreadError({
2574 driver: CursorAgentSdk.CURSOR_PROVIDER,
2575 providerThreadId: rollbackInput.providerThread.id,
2576 checkpointId: rollbackInput.target.checkpointId,
2577 cause: "Cursor Agent SDK does not expose conversation rollback.",
2578 }),
2579 ),
2580 forkThread: (forkInput) =>
2581 Effect.fail(
2582 new ProviderAdapter.ProviderAdapterForkThreadError({
2583 driver: CursorAgentSdk.CURSOR_PROVIDER,
2584 providerThreadId: forkInput.sourceProviderThread.id,
2585 cause: "Cursor Agent SDK does not expose native agent forks.",
2586 }),
2587 ),
2588 };
2589 return runtime;
2590 },
2591 (effect, input) =>
2592 effect.pipe(
2593 Effect.mapError(
2594 (cause) =>
2595 new ProviderAdapter.ProviderAdapterOpenSessionError({
2596 driver: CursorAgentSdk.CURSOR_PROVIDER,
2597 providerSessionId: input.providerSessionId,
2598 cause,
2599 }),
2600 ),
2601 ),
2602 ),
2603 });
2604}
2605
2606export type CursorAdapterV2DriverEnv =
2607 | CursorAgentSdk.CursorAgentSdkRunner
2608 | FileSystem.FileSystem
2609 | Path.Path
2610 | IdAllocator.IdAllocatorV2
2611 | ServerConfig.ServerConfig;
2612
2613export const CursorAdapterV2Driver: ProviderAdapterDriver<
2614 CursorSettings,
2615 CursorAdapterV2DriverEnv
2616> = {
2617 driverKind: CURSOR_DRIVER_KIND,
2618 configSchema: CursorSettings,
2619 defaultConfig: (): CursorSettings => DEFAULT_CURSOR_SETTINGS,
2620 create: Effect.fn("CursorAdapterV2Driver.create")(
2621 function* (input: ProviderAdapterDriverCreateInput<CursorSettings>) {
2622 const hostEnvironment = yield* HostProcessEnvironment;
2623 const fileSystem = yield* FileSystem.FileSystem;
2624 const path = yield* Path.Path;
2625 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2626 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
2627 const serverConfig = yield* ServerConfig.ServerConfig;
2628 return makeCursorAdapterV2({
2629 instanceId: input.instanceId,
2630 settings: {
2631 ...input.config,
2632 enabled: input.enabled,
2633 },
2634 environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
2635 fileSystem,
2636 path,
2637 idAllocator,
2638 runner,
2639 serverConfig,
2640 });
2641 },
2642 (effect, input) =>
2643 effect.pipe(
2644 Effect.mapError(
2645 (cause) =>
2646 new ProviderAdapterDriverCreateError({
2647 driver: CURSOR_DRIVER_KIND,
2648 instanceId: input.instanceId,
2649 detail: "Failed to create Cursor Agent SDK adapter.",
2650 cause,
2651 }),
2652 ),
2653 ),
2654 ),
2655};
2656
2657const layer: Layer.Layer<
2658 ProviderAdapter.ProviderAdapterV2,
2659 never,
2660 | CursorAgentSdk.CursorAgentSdkRunner
2661 | FileSystem.FileSystem
2662 | Path.Path
2663 | IdAllocator.IdAllocatorV2
2664 | ServerConfig.ServerConfig
2665> = Layer.effect(
2666 ProviderAdapter.ProviderAdapterV2,
2667 Effect.gen(function* () {
2668 const hostEnvironment = yield* HostProcessEnvironment;
2669 const fileSystem = yield* FileSystem.FileSystem;
2670 const path = yield* Path.Path;
2671 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2672 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
2673 const serverConfig = yield* ServerConfig.ServerConfig;
2674 return makeCursorAdapterV2({
2675 instanceId: CURSOR_DEFAULT_INSTANCE_ID,
2676 settings: DEFAULT_CURSOR_SETTINGS,
2677 environment: hostEnvironment,
2678 fileSystem,
2679 path,
2680 idAllocator,
2681 runner,
2682 serverConfig,
2683 });
2684 }),
2685);
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts deleted-304
...@@ -1,304 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { vi } from "vite-plus/test";
7
8import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
9import * as CursorAgentSdk from "./CursorAgentSdk.ts";
10
11const cursorSdkMock = vi.hoisted(() => {
12 const closeExecutionOrder: Array<string> = [];
13 const runOperations: Array<string> = [];
14 let agentCloseFailure: unknown;
15 let staleRunActive = false;
16 const runWait = vi.fn(async () => ({
17 id: "run-cursor-agent-sdk-test",
18 requestId: "request-cursor-agent-sdk-test",
19 status: "finished",
20 result: "ok",
21 model: { id: "default" },
22 durationMs: 1,
23 }));
24 const runCancel = vi.fn(async () => {});
25 const agentClose = vi.fn(() => {
26 closeExecutionOrder.push("sdk:agent.close");
27 if (agentCloseFailure !== undefined) {
28 throw agentCloseFailure;
29 }
30 });
31 const send = vi.fn(
32 async (
33 _message: unknown,
34 options: { readonly onDelta?: (input: { readonly update: unknown }) => Promise<void> },
35 ) => {
36 runOperations.push("run.start");
37 if (staleRunActive) {
38 throw new Error("Agent agent-cursor-agent-sdk-test already has active run");
39 }
40 await options.onDelta?.({
41 update: {
42 type: "assistant-message-chunk",
43 text: "hello",
44 },
45 });
46 return {
47 id: "run-cursor-agent-sdk-test",
48 agentId: "agent-cursor-agent-sdk-test",
49 wait: runWait,
50 cancel: runCancel,
51 };
52 },
53 );
54 const create = vi.fn(async () => ({
55 agentId: "agent-cursor-agent-sdk-test",
56 send,
57 close: agentClose,
58 }));
59 const resume = vi.fn(async () => ({
60 agentId: "agent-cursor-agent-sdk-test",
61 send,
62 close: agentClose,
63 }));
64 const listRuns = vi.fn(async () => {
65 runOperations.push("agent.listRuns");
66 return {
67 items: staleRunActive ? [{ id: "run-stale", status: "running" }] : [],
68 };
69 });
70 const cancelRun = vi.fn(async () => {
71 runOperations.push("agent.cancelRun");
72 staleRunActive = false;
73 });
74
75 return {
76 agentClose,
77 cancelRun,
78 closeExecutionOrder,
79 create,
80 listRuns,
81 resume,
82 runOperations,
83 runCancel,
84 runWait,
85 send,
86 setAgentCloseFailure: (failure: unknown) => {
87 agentCloseFailure = failure;
88 },
89 setStaleRunActive: (active: boolean) => {
90 staleRunActive = active;
91 },
92 };
93});
94
95vi.mock("../../provider/cursorSdk.ts", () => ({
96 Agent: {
97 create: cursorSdkMock.create,
98 resume: cursorSdkMock.resume,
99 listRuns: cursorSdkMock.listRuns,
100 cancelRun: cursorSdkMock.cancelRun,
101 messages: {
102 list: vi.fn(async () => []),
103 },
104 },
105}));
106
107const layerTest = CursorAgentSdk.layer.pipe(
108 Layer.provide(
109 Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, {
110 native: {
111 filePath: "cursor-agent-sdk-test.log",
112 write: (event: unknown) =>
113 Effect.sync(() => {
114 if (
115 typeof event === "object" &&
116 event !== null &&
117 Reflect.get(Reflect.get(event, "event"), "payload")?.type === "agent.close"
118 ) {
119 cursorSdkMock.closeExecutionOrder.push("protocol:agent.close");
120 }
121 }),
122 close: () => Effect.void,
123 },
124 canonical: undefined,
125 }),
126 ),
127 Layer.provide(NodeServices.layer),
128);
129
130describe("CursorAgentSdkRunner", () => {
131 it.effect("leaves a resumed agent alone when its next send succeeds", () =>
132 Effect.gen(function* () {
133 cursorSdkMock.runOperations.length = 0;
134 cursorSdkMock.setStaleRunActive(false);
135
136 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
137 const session = yield* runner.open({
138 operation: "resume",
139 agentId: "agent-cursor-agent-sdk-test",
140 options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } },
141 threadId: ThreadId.make("thread-cursor-agent-sdk-normal-resume-test"),
142 providerSessionId: ProviderSessionId.make(
143 "provider-session-cursor-agent-sdk-normal-resume-test",
144 ),
145 });
146 const run = yield* session.send({ message: "continue" });
147 yield* run.wait;
148
149 assert.deepStrictEqual(cursorSdkMock.runOperations, ["run.start"]);
150 }).pipe(Effect.provide(layerTest)),
151 );
152
153 it.effect("retries a resumed send after cancelling its abandoned local run", () =>
154 Effect.gen(function* () {
155 cursorSdkMock.runOperations.length = 0;
156 cursorSdkMock.listRuns.mockClear();
157 cursorSdkMock.cancelRun.mockClear();
158 cursorSdkMock.setStaleRunActive(true);
159
160 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
161 const session = yield* runner.open({
162 operation: "resume",
163 agentId: "agent-cursor-agent-sdk-test",
164 options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } },
165 threadId: ThreadId.make("thread-cursor-agent-sdk-stale-run-test"),
166 providerSessionId: ProviderSessionId.make(
167 "provider-session-cursor-agent-sdk-stale-run-test",
168 ),
169 });
170 assert.deepStrictEqual(cursorSdkMock.runOperations, []);
171 const run = yield* session.send({ message: "continue" });
172 yield* run.wait;
173
174 assert.deepStrictEqual(cursorSdkMock.runOperations, [
175 "run.start",
176 "agent.listRuns",
177 "agent.cancelRun",
178 "run.start",
179 ]);
180 assert.deepStrictEqual(cursorSdkMock.listRuns.mock.calls[0], [
181 "agent-cursor-agent-sdk-test",
182 { runtime: "local", cwd: process.cwd(), limit: 1 },
183 ]);
184 assert.deepStrictEqual(cursorSdkMock.cancelRun.mock.calls[0], [
185 "run-stale",
186 {
187 runtime: "local",
188 cwd: process.cwd(),
189 },
190 ]);
191 }).pipe(Effect.provide(layerTest)),
192 );
193
194 it.effect("logs agent.close before invoking the Cursor SDK", () =>
195 Effect.gen(function* () {
196 cursorSdkMock.agentClose.mockClear();
197 cursorSdkMock.closeExecutionOrder.length = 0;
198 cursorSdkMock.setAgentCloseFailure(undefined);
199
200 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
201 const session = yield* runner.open({
202 operation: "create",
203 options: {
204 model: { id: "default" },
205 mode: "agent",
206 local: {
207 cwd: process.cwd(),
208 },
209 },
210 threadId: ThreadId.make("thread-cursor-agent-sdk-close-order-test"),
211 providerSessionId: ProviderSessionId.make(
212 "provider-session-cursor-agent-sdk-close-order-test",
213 ),
214 });
215
216 cursorSdkMock.closeExecutionOrder.length = 0;
217 yield* session.close;
218
219 assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [
220 "protocol:agent.close",
221 "sdk:agent.close",
222 ]);
223 assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1);
224 }).pipe(Effect.provide(layerTest)),
225 );
226
227 it.effect("logs agent.close even when the Cursor SDK close fails", () =>
228 Effect.gen(function* () {
229 cursorSdkMock.agentClose.mockClear();
230 cursorSdkMock.closeExecutionOrder.length = 0;
231 const closeFailure = new Error("agent close failed");
232 cursorSdkMock.setAgentCloseFailure(closeFailure);
233
234 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
235 const session = yield* runner.open({
236 operation: "create",
237 options: {
238 model: { id: "default" },
239 mode: "agent",
240 local: {
241 cwd: process.cwd(),
242 },
243 },
244 threadId: ThreadId.make("thread-cursor-agent-sdk-close-failure-test"),
245 providerSessionId: ProviderSessionId.make(
246 "provider-session-cursor-agent-sdk-close-failure-test",
247 ),
248 });
249
250 cursorSdkMock.closeExecutionOrder.length = 0;
251 const error = yield* Effect.flip(session.close);
252
253 assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError);
254 assert.equal(error.method, "agent.close");
255 assert.strictEqual(error.cause, closeFailure);
256 assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [
257 "protocol:agent.close",
258 "sdk:agent.close",
259 ]);
260 assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1);
261 cursorSdkMock.setAgentCloseFailure(undefined);
262 }).pipe(Effect.provide(layerTest)),
263 );
264
265 it.effect("surfaces interaction callback failures through run.wait", () =>
266 Effect.gen(function* () {
267 cursorSdkMock.create.mockClear();
268 cursorSdkMock.runWait.mockClear();
269 cursorSdkMock.send.mockClear();
270
271 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
272 const session = yield* runner.open({
273 operation: "create",
274 options: {
275 model: { id: "default" },
276 mode: "agent",
277 local: {
278 cwd: process.cwd(),
279 autoReview: false,
280 sandboxOptions: { enabled: false },
281 enableAgentRetries: true,
282 },
283 },
284 threadId: ThreadId.make("thread-cursor-agent-sdk-test"),
285 providerSessionId: ProviderSessionId.make("provider-session-cursor-agent-sdk-test"),
286 });
287
288 const callbackFailure = new Error("delta callback failed");
289 const run = yield* session.send({
290 message: "hello",
291 onDelta: () => Effect.fail(callbackFailure),
292 });
293
294 const error = yield* Effect.flip(run.wait);
295
296 assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError);
297 assert.equal(error.method, "run.wait");
298 assert.strictEqual(error.cause, callbackFailure);
299 assert.equal(cursorSdkMock.create.mock.calls.length, 1);
300 assert.equal(cursorSdkMock.send.mock.calls.length, 1);
301 assert.equal(cursorSdkMock.runWait.mock.calls.length, 1);
302 }).pipe(Effect.provide(layerTest)),
303 );
304});
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts deleted-612
...@@ -1,612 +0,0 @@
1import type {
2 AgentMessage,
3 AgentOptions,
4 InteractionUpdate,
5 RunResult,
6 SDKUserMessage,
7 SendOptions,
8} from "@cursor/sdk";
9import {
10 type OrchestrationV2ProviderSession,
11 ProviderDriverKind,
12 type ThreadId,
13} from "@t3tools/contracts";
14import * as Context from "effect/Context";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";
18
19import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts";
20import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
21import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
22
23export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const;
24export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor");
25
26export class CursorAgentSdkRunnerError extends Schema.TaggedError<CursorAgentSdkRunnerError>()(
27 "CursorAgentSdkRunnerError",
28 {
29 method: Schema.String,
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `Cursor Agent SDK ${this.method} failed.`;
35 }
36}
37
38const isCursorAgentSdkRunnerError = Schema.is(CursorAgentSdkRunnerError);
39
40export interface CursorAgentSdkOpenInput {
41 readonly operation: "create" | "resume";
42 readonly agentId?: string;
43 readonly options: AgentOptions;
44 readonly threadId: ThreadId;
45 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
46}
47
48export interface CursorAgentSdkSendInput<Error> {
49 readonly message: string | SDKUserMessage;
50 readonly options?: Omit<SendOptions, "onDelta">;
51 readonly onDelta?: (update: InteractionUpdate) => Effect.Effect<void, Error>;
52}
53
54export interface CursorAgentSdkRun {
55 readonly runId: string;
56 readonly agentId: string;
57 readonly wait: Effect.Effect<RunResult, CursorAgentSdkRunnerError>;
58 readonly cancel: Effect.Effect<void, CursorAgentSdkRunnerError>;
59}
60
61export interface CursorAgentSdkSession {
62 readonly agentId: string;
63 readonly send: <Error>(
64 input: CursorAgentSdkSendInput<Error>,
65 ) => Effect.Effect<CursorAgentSdkRun, CursorAgentSdkRunnerError>;
66 readonly listMessages: Effect.Effect<ReadonlyArray<AgentMessage>, CursorAgentSdkRunnerError>;
67 readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>;
68}
69
70export interface CursorAgentSdkRunnerShape {
71 readonly open: (
72 input: CursorAgentSdkOpenInput,
73 ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>;
74 readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>;
75}
76
77export class CursorAgentSdkRunner extends Context.Service<
78 CursorAgentSdkRunner,
79 CursorAgentSdkRunnerShape
80>()("t3/orchestration-v2/Adapters/CursorAgentSdk/CursorAgentSdkRunner") {}
81
82export interface CursorAgentSdkLoggedAgentOptions {
83 readonly model?: AgentOptions["model"];
84 readonly hasName?: boolean;
85 readonly mode?: AgentOptions["mode"];
86 readonly local?: {
87 readonly hasCwd?: boolean;
88 readonly autoReview?: boolean;
89 readonly settingSources?: AgentOptions["local"] extends infer Local
90 ? Local extends { readonly settingSources?: infer Sources }
91 ? Sources
92 : never
93 : never;
94 readonly sandboxEnabled?: boolean;
95 readonly enableAgentRetries?: boolean;
96 readonly hasCustomTools?: boolean;
97 };
98 readonly agents?: ReadonlyArray<string>;
99}
100
101export interface CursorAgentSdkLoggedSendOptions {
102 readonly model?: SendOptions["model"];
103 readonly mode?: SendOptions["mode"];
104 readonly local?: {
105 readonly force?: boolean;
106 readonly hasCustomTools?: boolean;
107 };
108 readonly idempotencyKey?: string;
109}
110
111export type CursorAgentSdkProtocolLogEvent =
112 | {
113 readonly direction: "outgoing";
114 readonly stage: "decoded";
115 readonly payload: {
116 readonly type: "agent.open";
117 readonly operation: CursorAgentSdkOpenInput["operation"];
118 readonly agentId?: string;
119 readonly options: CursorAgentSdkLoggedAgentOptions;
120 };
121 }
122 | {
123 readonly direction: "incoming";
124 readonly stage: "decoded";
125 readonly payload: {
126 readonly type: "agent.opened";
127 readonly agentId: string;
128 };
129 }
130 | {
131 readonly direction: "outgoing";
132 readonly stage: "decoded";
133 readonly payload: {
134 readonly type: "run.start";
135 readonly message: string | SDKUserMessage;
136 readonly options: CursorAgentSdkLoggedSendOptions;
137 };
138 }
139 | {
140 readonly direction: "incoming";
141 readonly stage: "decoded";
142 readonly payload: {
143 readonly type: "run.started";
144 readonly runId: string;
145 readonly agentId: string;
146 };
147 }
148 | {
149 readonly direction: "incoming";
150 readonly stage: "decoded";
151 readonly payload: {
152 readonly type: "interaction.update";
153 readonly runId: string;
154 readonly update: InteractionUpdate;
155 };
156 }
157 | {
158 readonly direction: "incoming";
159 readonly stage: "decoded";
160 readonly payload: {
161 readonly type: "run.completed";
162 readonly result: RunResult;
163 };
164 }
165 | {
166 readonly direction: "outgoing";
167 readonly stage: "decoded";
168 readonly payload: {
169 readonly type: "run.cancel";
170 readonly runId: string;
171 };
172 }
173 | {
174 readonly direction: "outgoing";
175 readonly stage: "decoded";
176 readonly payload: {
177 readonly type: "agent.messages.list";
178 readonly agentId: string;
179 };
180 }
181 | {
182 readonly direction: "incoming";
183 readonly stage: "decoded";
184 readonly payload: {
185 readonly type: "agent.messages";
186 readonly agentId: string;
187 readonly messages: ReadonlyArray<AgentMessage>;
188 };
189 }
190 | {
191 readonly direction: "outgoing";
192 readonly stage: "decoded";
193 readonly payload: {
194 readonly type: "agent.close";
195 readonly agentId: string;
196 };
197 };
198
199export type CursorAgentSdkProtocolLogger = (
200 event: CursorAgentSdkProtocolLogEvent,
201) => Effect.Effect<void>;
202
203function runnerError(cause: unknown, method: string): CursorAgentSdkRunnerError {
204 return isCursorAgentSdkRunnerError(cause)
205 ? cause
206 : new CursorAgentSdkRunnerError({ method, cause });
207}
208
209function isActiveRunConflict(cause: unknown): boolean {
210 return cause instanceof Error && /already has active run/i.test(cause.message);
211}
212
213export function isCursorCancellationError(cause: unknown): boolean {
214 let current = cause;
215 const seen = new Set<object>();
216
217 while (typeof current === "object" && current !== null && !seen.has(current)) {
218 if (Reflect.get(current, "name") === "AbortError") {
219 return true;
220 }
221 seen.add(current);
222 current = Reflect.get(current, "cause");
223 }
224
225 return false;
226}
227
228export function loggedCursorAgentOptions(options: AgentOptions): CursorAgentSdkLoggedAgentOptions {
229 return {
230 ...(options.model === undefined ? {} : { model: options.model }),
231 ...(options.name === undefined ? {} : { hasName: true }),
232 ...(options.mode === undefined ? {} : { mode: options.mode }),
233 ...(options.local === undefined
234 ? {}
235 : {
236 local: {
237 ...(options.local.cwd === undefined ? {} : { hasCwd: true }),
238 ...(options.local.autoReview === undefined
239 ? {}
240 : { autoReview: options.local.autoReview }),
241 ...(options.local.settingSources === undefined
242 ? {}
243 : { settingSources: options.local.settingSources }),
244 ...(options.local.sandboxOptions === undefined
245 ? {}
246 : { sandboxEnabled: options.local.sandboxOptions.enabled }),
247 ...(options.local.enableAgentRetries === undefined
248 ? {}
249 : { enableAgentRetries: options.local.enableAgentRetries }),
250 ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }),
251 },
252 }),
253 ...(options.agents === undefined ? {} : { agents: Object.keys(options.agents).toSorted() }),
254 };
255}
256
257export function loggedCursorSendOptions(
258 options: Omit<SendOptions, "onDelta"> | undefined,
259): CursorAgentSdkLoggedSendOptions {
260 if (options === undefined) {
261 return {};
262 }
263 return {
264 ...(options.model === undefined ? {} : { model: options.model }),
265 ...(options.mode === undefined ? {} : { mode: options.mode }),
266 ...(options.local === undefined
267 ? {}
268 : {
269 local: {
270 ...(options.local.force === undefined ? {} : { force: options.local.force }),
271 ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }),
272 },
273 }),
274 ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }),
275 };
276}
277
278function makeCursorAgentSdkProtocolLogger(input: {
279 readonly nativeEventLogger: EventNdjsonLogger | undefined;
280 readonly threadId: ThreadId;
281 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
282}): CursorAgentSdkProtocolLogger | undefined {
283 if (input.nativeEventLogger === undefined) {
284 return undefined;
285 }
286 const nativeEventLogger = input.nativeEventLogger;
287 return (event) =>
288 nativeEventLogger
289 .write(
290 {
291 provider: CURSOR_PROVIDER,
292 protocol: CURSOR_AGENT_SDK_PROTOCOL,
293 kind: "protocol",
294 providerSessionId: input.providerSessionId,
295 event,
296 },
297 input.threadId,
298 )
299 .pipe(Effect.ignore);
300}
301
302/**
303 * The Cursor SDK decides once per process whether local sandboxing works, and
304 * caches the answer the first time any run starts. Only sandboxed runs point
305 * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access)
306 * run it caches "unsupported" and rejects every later sandboxed run until the
307 * server restarts. Warming a bare sandboxed executor before the first
308 * unsandboxed agent opens lets the SDK find the helper and cache the real
309 * answer. Warming is best effort: on a machine without sandbox support it
310 * fails, the SDK caches "unsupported", and sandboxed runs report that as
311 * before.
312 */
313let cursorSandboxSupportPrime: Promise<void> | undefined;
314
315function primeCursorSandboxSupport(options: AgentOptions): Promise<void> {
316 cursorSandboxSupportPrime ??= (async () => {
317 const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined;
318 const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd });
319 const release = await platform.prewarmLocalWorkspace({
320 ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
321 local: {
322 ...(cwd === undefined ? {} : { cwd }),
323 settingSources: [],
324 sandboxOptions: { enabled: true },
325 },
326 });
327 await release();
328 })().catch(() => undefined);
329 return cursorSandboxSupportPrime;
330}
331
332/**
333 * Runs agents through the Cursor SDK, logging every frame to the protocol
334 * logger chosen for each opened agent. The live layer writes the native
335 * provider event log; the replay recorder turns the same frames into a
336 * transcript.
337 */
338export function makeCursorAgentSdkRunner(
339 protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined,
340): CursorAgentSdkRunnerShape {
341 return CursorAgentSdkRunner.of({
342 open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) {
343 if (input.options.local?.sandboxOptions?.enabled === false) {
344 yield* Effect.promise(() => primeCursorSandboxSupport(input.options));
345 }
346 const protocolLogger = protocolLoggerFor(input);
347 const log = (event: CursorAgentSdkProtocolLogEvent) =>
348 protocolLogger === undefined ? Effect.void : protocolLogger(event);
349
350 yield* log({
351 direction: "outgoing",
352 stage: "decoded",
353 payload: {
354 type: "agent.open",
355 operation: input.operation,
356 ...(input.agentId === undefined ? {} : { agentId: input.agentId }),
357 options: loggedCursorAgentOptions(input.options),
358 },
359 });
360
361 const agent = yield* Effect.tryPromise({
362 try: () =>
363 input.operation === "create"
364 ? Agent.create(input.options)
365 : Agent.resume(input.agentId!, input.options),
366 catch: (cause) => runnerError(cause, `agent.${input.operation}`),
367 });
368
369 yield* log({
370 direction: "incoming",
371 stage: "decoded",
372 payload: {
373 type: "agent.opened",
374 agentId: agent.agentId,
375 },
376 });
377
378 const cwd =
379 typeof input.options.local?.cwd === "string"
380 ? input.options.local.cwd
381 : input.options.local?.cwd?.[0];
382 const runOptions = {
383 runtime: "local" as const,
384 ...(cwd === undefined ? {} : { cwd }),
385 ...(input.options.local?.store === undefined ? {} : { store: input.options.local.store }),
386 };
387 let abandonedRunRecoveryAvailable = input.operation === "resume";
388
389 return {
390 agentId: agent.agentId,
391 send: Effect.fn("CursorAgentSdkSession.send")(function* (sendInput) {
392 const context = yield* Effect.context();
393 yield* log({
394 direction: "outgoing",
395 stage: "decoded",
396 payload: {
397 type: "run.start",
398 message: sendInput.message,
399 options: loggedCursorSendOptions(sendInput.options),
400 },
401 });
402
403 let callbacksReady = false;
404 const pendingUpdates: Array<InteractionUpdate> = [];
405 let callbackFailure: { readonly cause: unknown } | undefined;
406 let callbackChain = Promise.resolve();
407 let runId = "";
408 const dispatchUpdate = (update: InteractionUpdate): Promise<void> => {
409 callbackChain = callbackChain
410 .then(() => {
411 if (callbackFailure !== undefined) {
412 return;
413 }
414 return Effect.runPromiseWith(context)(
415 log({
416 direction: "incoming",
417 stage: "decoded",
418 payload: {
419 type: "interaction.update",
420 runId,
421 update,
422 },
423 }).pipe(Effect.andThen(sendInput.onDelta?.(update) ?? Effect.void)),
424 );
425 })
426 .catch((cause) => {
427 callbackFailure ??= { cause };
428 });
429 return callbackChain;
430 };
431
432 const startRun = () =>
433 Effect.tryPromise({
434 try: () =>
435 agent.send(sendInput.message, {
436 ...sendInput.options,
437 onDelta: async ({ update }) => {
438 if (!callbacksReady) {
439 pendingUpdates.push(update);
440 return;
441 }
442 await dispatchUpdate(update);
443 },
444 }),
445 catch: (cause) => runnerError(cause, "run.start"),
446 });
447 const run = yield* startRun().pipe(
448 Effect.catchIf(
449 (error) => abandonedRunRecoveryAvailable && isActiveRunConflict(error.cause),
450 (error) =>
451 Effect.gen(function* () {
452 abandonedRunRecoveryAvailable = false;
453 const latestRun = yield* Effect.tryPromise({
454 try: () => Agent.listRuns(agent.agentId, { ...runOptions, limit: 1 }),
455 catch: (cause) => runnerError(cause, "agent.listRuns"),
456 });
457 const activeRun = latestRun.items.find(
458 (candidate) => candidate.status === "running",
459 );
460 if (activeRun === undefined) {
461 return yield* error;
462 }
463 yield* log({
464 direction: "outgoing",
465 stage: "decoded",
466 payload: { type: "run.cancel", runId: activeRun.id },
467 });
468 yield* Effect.tryPromise({
469 try: () => Agent.cancelRun(activeRun.id, runOptions),
470 catch: (cause) => runnerError(cause, "agent.cancelRun"),
471 });
472 return yield* startRun();
473 }),
474 ),
475 );
476 abandonedRunRecoveryAvailable = false;
477 runId = run.id;
478 yield* log({
479 direction: "incoming",
480 stage: "decoded",
481 payload: {
482 type: "run.started",
483 runId: run.id,
484 agentId: run.agentId,
485 },
486 });
487 callbacksReady = true;
488 for (const update of pendingUpdates) {
489 yield* Effect.tryPromise({
490 try: () => dispatchUpdate(update),
491 catch: (cause) => runnerError(cause, "run.onDelta"),
492 });
493 }
494
495 return {
496 runId: run.id,
497 agentId: run.agentId,
498 wait: Effect.tryPromise({
499 try: async () => {
500 const result = await run.wait();
501 await callbackChain;
502 if (callbackFailure !== undefined) {
503 throw callbackFailure.cause;
504 }
505 return result;
506 },
507 catch: (cause) => runnerError(cause, "run.wait"),
508 }).pipe(
509 Effect.tap((result) =>
510 log({
511 direction: "incoming",
512 stage: "decoded",
513 payload: {
514 type: "run.completed",
515 result,
516 },
517 }),
518 ),
519 ),
520 cancel: log({
521 direction: "outgoing",
522 stage: "decoded",
523 payload: {
524 type: "run.cancel",
525 runId: run.id,
526 },
527 }).pipe(
528 Effect.andThen(
529 Effect.tryPromise({
530 try: async () => {
531 try {
532 await run.cancel();
533 } catch (cause) {
534 if (!isCursorCancellationError(cause)) {
535 throw cause;
536 }
537 }
538 },
539 catch: (cause) => runnerError(cause, "run.cancel"),
540 }),
541 ),
542 ),
543 } satisfies CursorAgentSdkRun;
544 }),
545 listMessages: log({
546 direction: "outgoing",
547 stage: "decoded",
548 payload: {
549 type: "agent.messages.list",
550 agentId: agent.agentId,
551 },
552 }).pipe(
553 Effect.andThen(
554 Effect.tryPromise({
555 try: () =>
556 Agent.messages.list(agent.agentId, {
557 runtime: "local",
558 ...(cwd === undefined ? {} : { cwd }),
559 }),
560 catch: (cause) => runnerError(cause, "agent.messages.list"),
561 }),
562 ),
563 Effect.tap((messages) =>
564 log({
565 direction: "incoming",
566 stage: "decoded",
567 payload: {
568 type: "agent.messages",
569 agentId: agent.agentId,
570 messages,
571 },
572 }),
573 ),
574 ),
575 close: log({
576 direction: "outgoing",
577 stage: "decoded",
578 payload: {
579 type: "agent.close",
580 agentId: agent.agentId,
581 },
582 }).pipe(
583 Effect.andThen(
584 Effect.try({
585 try: () => agent.close(),
586 catch: (cause) => runnerError(cause, "agent.close"),
587 }),
588 ),
589 ),
590 } satisfies CursorAgentSdkSession;
591 }),
592 assertComplete: Effect.void,
593 });
594}
595
596export const layer: Layer.Layer<
597 CursorAgentSdkRunner,
598 never,
599 ProviderEventLoggers.ProviderEventLoggers
600> = Layer.effect(
601 CursorAgentSdkRunner,
602 Effect.gen(function* () {
603 const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;
604 return makeCursorAgentSdkRunner((input) =>
605 makeCursorAgentSdkProtocolLogger({
606 nativeEventLogger,
607 threadId: input.threadId,
608 providerSessionId: input.providerSessionId,
609 }),
610 );
611 }),
612);
apps/server/src/orchestration-v2/CommandPolicy.test.ts+1-1
...@@ -12,7 +12,7 @@ import {...@@ -12,7 +12,7 @@ import {
12import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
1313
14import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";14import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
15import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts";15import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing";
16import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts";16import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts";
17import * as CommandPolicy from "./CommandPolicy.ts";17import * as CommandPolicy from "./CommandPolicy.ts";
1818
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+1-1
...@@ -12,7 +12,7 @@ import { CodexAdapterV2Driver, type CodexAdapterV2DriverEnv } from "./Adapters/C...@@ -12,7 +12,7 @@ import { CodexAdapterV2Driver, type CodexAdapterV2DriverEnv } from "./Adapters/C
12import {12import {
13 CursorAdapterV2Driver,13 CursorAdapterV2Driver,
14 type CursorAdapterV2DriverEnv,14 type CursorAdapterV2DriverEnv,
15} from "./Adapters/CursorAdapterV2.ts";15} from "@t3tools/provider-cursor/server";
16import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts";16import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts";
17import {17import {
18 OpenCodeAdapterV2Driver,18 OpenCodeAdapterV2Driver,
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+1-1
...@@ -43,7 +43,7 @@ import {...@@ -43,7 +43,7 @@ import {
43 canReuseCodexContextUsage,43 canReuseCodexContextUsage,
44} from "../Adapters/CodexAdapterV2.ts";44} from "../Adapters/CodexAdapterV2.ts";
45import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts";45import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts";
46import { CursorProviderCapabilitiesV2 } from "../Adapters/CursorAdapterV2.ts";46import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing";
47import * as EventSink from "../EventSink.ts";47import * as EventSink from "../EventSink.ts";
48import * as EventStore from "../EventStore.ts";48import * as EventStore from "../EventStore.ts";
49import * as LegacyV1ThreadImporter from "../legacy/LegacyV1ThreadImporter.ts";49import * as LegacyV1ThreadImporter from "../legacy/LegacyV1ThreadImporter.ts";
apps/server/src/provider/AntigravityProvider.test.ts+3-1
...@@ -22,6 +22,7 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";...@@ -22,6 +22,7 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
22import * as ServerConfig from "../config.ts";22import * as ServerConfig from "../config.ts";
23import * as ServerSettings from "../serverSettings.ts";23import * as ServerSettings from "../serverSettings.ts";
24import * as ProviderHostLive from "./ProviderHostLive.ts";24import * as ProviderHostLive from "./ProviderHostLive.ts";
25import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
25import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";26import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
26import {27import {
27 buildAntigravityModelsFromSession,28 buildAntigravityModelsFromSession,
...@@ -116,9 +117,10 @@ const layerTest = ProviderHostLive.layer.pipe(...@@ -116,9 +117,10 @@ const layerTest = ProviderHostLive.layer.pipe(
116 }),117 }),
117 ),118 ),
118 Layer.provideMerge(ServerSettings.layerTest()),119 Layer.provideMerge(ServerSettings.layerTest()),
120 Layer.provideMerge(ServerSecretStore.layer),
119 Layer.provide(121 Layer.provide(
120 ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-provider-test-" }).pipe(122 ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-provider-test-" }).pipe(
121 Layer.provide(NodeServices.layer),123 Layer.provideMerge(NodeServices.layer),
122 ),124 ),
123 ),125 ),
124);126);
apps/server/src/provider/CursorAuth.test.ts deleted-356
...@@ -1,356 +0,0 @@
1import type { SdkLoginOptions, StoredSdkCredentials } from "@cursor/sdk";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts";
5import * as Clock from "effect/Clock";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as Fiber from "effect/Fiber";
10import * as Option from "effect/Option";
11import * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14
15import { makeCursorAuth, type CursorAuthOptions } from "./CursorAuth.ts";
16import { InMemoryCredentialStore } from "./cursorSdk.ts";
17
18const instanceId = ProviderInstanceId.make("cursor-personal");
19const owner = "owner-client";
20const otherOwner = "other-client";
21const authorizationUrl = "https://cursor.com/loginDeepControl?challenge=test-challenge";
22
23const makeHarness = Effect.fn("makeCursorAuthHarness")(function* (
24 overrides: Partial<CursorAuthOptions> = {},
25) {
26 const store = new InMemoryCredentialStore();
27 const complete = Promise.withResolvers<void>();
28 const returned = Promise.withResolvers<void>();
29 const now = yield* Clock.currentTimeMillis;
30 const credentials: StoredSdkCredentials = {
31 version: 1,
32 backendUrl: "https://api2.cursor.sh",
33 apiKey: "browser-key",
34 createdAtMs: now,
35 apiKeyExpiresAtMs: now + 86_400_000,
36 email: "cursor@example.com",
37 };
38 let loginOptions: SdkLoginOptions | undefined;
39 const changes: boolean[] = [];
40 const auth = yield* makeCursorAuth({
41 instanceId,
42 displayName: "Personal Cursor",
43 enabled: true,
44 store,
45 onChanged: (signedIn) =>
46 Effect.sync(() => {
47 changes.push(signedIn);
48 }),
49 login: async (options) => {
50 loginOptions = options;
51 options.onLoginUrl?.(authorizationUrl);
52 await complete.promise;
53 await options.store?.save(credentials);
54 returned.resolve();
55 return {
56 apiKey: credentials.apiKey,
57 apiKeyExpiresAtMs: credentials.apiKeyExpiresAtMs!,
58 email: credentials.email!,
59 };
60 },
61 ...overrides,
62 });
63 return {
64 auth,
65 store,
66 complete,
67 returned,
68 credentials,
69 changes,
70 loginOptions: () => loginOptions,
71 };
72});
73
74type Auth = Effect.Success<ReturnType<typeof makeCursorAuth>>;
75const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) =>
76 auth.controller.subscribe(session).pipe(
77 Stream.filter((state) => state.phase === phase),
78 Stream.runHead,
79 Effect.map(Option.getOrThrow),
80 );
81
82it.layer(NodeServices.layer)("CursorAuth", (it) => {
83 it.effect("closes SDK sessions and interrupts in-flight credential work before signing out", () =>
84 Effect.gen(function* () {
85 const harness = yield* makeHarness();
86 yield* Effect.promise(() => harness.store.save(harness.credentials));
87 const acquired = yield* Deferred.make<void>();
88 let closedSession = false;
89 let closedRequest = false;
90 yield* harness.auth.withAccess(
91 Effect.acquireRelease(Effect.succeed("session"), () =>
92 Effect.sync(() => {
93 closedSession = true;
94 }),
95 ),
96 );
97 const request = yield* harness.auth
98 .withAccess(
99 Effect.gen(function* () {
100 yield* Effect.addFinalizer(() =>
101 Effect.sync(() => {
102 closedRequest = true;
103 }),
104 );
105 yield* Deferred.succeed(acquired, undefined);
106 return yield* Effect.never;
107 }),
108 )
109 .pipe(Effect.forkScoped);
110 yield* Deferred.await(acquired);
111 yield* harness.auth.controller.logout(Effect.void);
112 expect(closedSession).toBe(true);
113 expect(closedRequest).toBe(true);
114 expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true);
115 expect(yield* harness.auth.readApiKey).toBeUndefined();
116 }).pipe(Effect.scoped),
117 );
118
119 it.effect("completes a remote browser login, saves its key and refreshes provider status", () =>
120 Effect.gen(function* () {
121 const harness = yield* makeHarness();
122 let stopped = false;
123 expect(yield* harness.auth.readApiKey).toBeUndefined();
124 const starting = yield* harness.auth.controller.start(
125 owner,
126 Effect.sync(() => {
127 stopped = true;
128 }),
129 );
130 const waiting = yield* phase(harness.auth, "waiting");
131 expect(stopped).toBe(true);
132 expect(waiting.authorizationUrl).toBe(authorizationUrl);
133 expect(waiting.flowId).toBe(starting.flowId);
134 expect(harness.loginOptions()?.openBrowser).toBe(false);
135 expect((yield* harness.auth.controller.start(owner)).flowId).toBe(starting.flowId);
136 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
137 yield* Effect.sync(() => harness.complete.resolve());
138 const succeeded = yield* phase(harness.auth, "succeeded");
139 expect(succeeded.authorizationUrl).toBeNull();
140 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
141 expect(harness.changes).toEqual([true]);
142 expect(Object.values(succeeded)).not.toContain("browser-key");
143 expect(yield* Effect.promise(() => harness.store.load())).toEqual(harness.credentials);
144 }).pipe(Effect.scoped),
145 );
146
147 it.effect("keeps the login link private to its owner and rejects other clients' mutations", () =>
148 Effect.gen(function* () {
149 const { auth } = yield* makeHarness();
150 yield* auth.controller.start(owner);
151 const waiting = yield* phase(auth, "waiting");
152 const foreign = yield* phase(auth, "waiting", otherOwner);
153 expect(foreign).toMatchObject({ flowId: null, authorizationUrl: null, expiresAt: null });
154 expect(Exit.isFailure(yield* Effect.exit(auth.controller.start(otherOwner)))).toBe(true);
155 expect(
156 Exit.isFailure(yield* Effect.exit(auth.controller.cancel(otherOwner, waiting.flowId!))),
157 ).toBe(true);
158 expect(
159 Exit.isFailure(
160 yield* Effect.exit(
161 auth.controller.complete(owner, {
162 flowId: waiting.flowId!,
163 callbackUrl: "http://localhost/callback",
164 }),
165 ),
166 ),
167 ).toBe(true);
168 expect((yield* phase(auth, "waiting")).authorizationUrl).toBe(authorizationUrl);
169 }).pipe(Effect.scoped),
170 );
171
172 it.effect("aborts cancellation and ignores a late successful SDK login", () =>
173 Effect.gen(function* () {
174 const harness = yield* makeHarness();
175 yield* harness.auth.controller.start(owner);
176 const waiting = yield* phase(harness.auth, "waiting");
177 const cancelled = yield* harness.auth.controller.cancel(owner, waiting.flowId!);
178 expect(cancelled.phase).toBe("cancelled");
179 expect(cancelled.authorizationUrl).toBeNull();
180 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
181 yield* Effect.sync(() => harness.complete.resolve());
182 yield* Effect.promise(() => harness.returned.promise);
183 expect(yield* harness.auth.readApiKey).toBeUndefined();
184 expect(harness.changes).toEqual([]);
185 }).pipe(Effect.scoped),
186 );
187
188 it.effect("times out pending logins and permits a retry", () =>
189 Effect.gen(function* () {
190 const harness = yield* makeHarness();
191 yield* harness.auth.controller.start(owner);
192 const waiting = yield* phase(harness.auth, "waiting");
193 yield* TestClock.adjust(300_000);
194 expect((yield* phase(harness.auth, "failed")).authorizationUrl).toBeNull();
195 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
196 const retry = yield* harness.auth.controller.start(owner);
197 expect(retry.flowId).not.toBe(waiting.flowId);
198 }).pipe(Effect.scoped),
199 );
200
201 it.effect("finishes saving a completed login atomically across the timeout", () =>
202 Effect.gen(function* () {
203 const store = new InMemoryCredentialStore();
204 const saving = Promise.withResolvers<void>();
205 const finishSave = Promise.withResolvers<void>();
206 const scope = yield* Scope.make();
207 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
208 const harness = yield* makeHarness({
209 store: {
210 load: () => store.load(),
211 clear: () => store.clear(),
212 save: async (credentials) => {
213 saving.resolve();
214 await finishSave.promise;
215 await store.save(credentials);
216 },
217 },
218 }).pipe(Effect.provideService(Scope.Scope, scope));
219 yield* Effect.addFinalizer(() => Effect.sync(() => finishSave.resolve()));
220 yield* harness.auth.controller.start(owner);
221 yield* phase(harness.auth, "waiting");
222 yield* Effect.sync(() => harness.complete.resolve());
223 yield* Effect.promise(() => saving.promise);
224 yield* TestClock.adjust(300_000);
225 yield* Effect.sync(() => finishSave.resolve());
226 yield* phase(harness.auth, "succeeded");
227 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
228 yield* Scope.close(scope, Exit.void);
229 const state = yield* harness.auth.controller.subscribe(owner).pipe(Stream.runHead);
230 expect(Option.getOrThrow(state).phase).toBe("succeeded");
231 expect(harness.changes).toEqual([true]);
232 }).pipe(Effect.scoped),
233 );
234
235 it.effect("discards a credential rejected by verification and permits a fresh login", () =>
236 Effect.gen(function* () {
237 let rejected = true;
238 const harness = yield* makeHarness({
239 onChanged: (signedIn) =>
240 signedIn && rejected
241 ? Effect.fail(
242 new ProviderSetupError({
243 instanceId,
244 operation: "start",
245 detail: "Could not verify the Cursor sign-in.",
246 }),
247 )
248 : Effect.void,
249 });
250 yield* harness.auth.controller.start(owner);
251 yield* phase(harness.auth, "waiting");
252 yield* Effect.sync(() => harness.complete.resolve());
253 yield* phase(harness.auth, "failed");
254 expect(yield* Effect.promise(() => harness.store.load())).toBeUndefined();
255 expect(yield* harness.auth.readApiKey).toBeUndefined();
256 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
257 rejected = false;
258 yield* harness.auth.controller.start(owner);
259 yield* phase(harness.auth, "succeeded");
260 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
261 }).pipe(Effect.scoped),
262 );
263
264 it.effect("clears only this instance's credential after its sessions stop", () =>
265 Effect.gen(function* () {
266 const harness = yield* makeHarness();
267 const other = yield* makeHarness({ instanceId: ProviderInstanceId.make("cursor-work") });
268 yield* Effect.promise(() => harness.store.save(harness.credentials));
269 yield* Effect.promise(() => other.store.save({ ...other.credentials, apiKey: "other-key" }));
270 const state = yield* harness.auth.controller.logout(
271 Effect.gen(function* () {
272 expect(yield* harness.auth.readApiKey).toBe("browser-key");
273 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
274 }),
275 );
276 expect(state.message).toBe("Signed out of Cursor.");
277 expect(yield* harness.auth.readApiKey).toBeUndefined();
278 expect(yield* other.auth.requireApiKey).toBe("other-key");
279 expect(harness.changes).toEqual([false]);
280 }).pipe(Effect.scoped),
281 );
282
283 it.effect("cancels a pending sign-in on logout without saving its eventual key", () =>
284 Effect.gen(function* () {
285 const harness = yield* makeHarness();
286 yield* harness.auth.controller.start(owner);
287 yield* phase(harness.auth, "waiting");
288 yield* harness.auth.controller.logout(Effect.void);
289 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
290 yield* Effect.sync(() => harness.complete.resolve());
291 yield* Effect.promise(() => harness.returned.promise);
292 expect(yield* harness.auth.readApiKey).toBeUndefined();
293 }).pipe(Effect.scoped),
294 );
295
296 it.effect("retains credentials if sessions cannot be stopped for logout", () =>
297 Effect.gen(function* () {
298 const harness = yield* makeHarness();
299 yield* Effect.promise(() => harness.store.save(harness.credentials));
300 const result = yield* Effect.exit(
301 harness.auth.controller.logout(
302 Effect.fail(
303 new ProviderSetupError({
304 instanceId,
305 operation: "stop",
306 detail: "Unable to stop sessions",
307 }),
308 ),
309 ),
310 );
311 expect(Exit.isFailure(result)).toBe(true);
312 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
313 }).pipe(Effect.scoped),
314 );
315
316 it.effect(
317 "prefers explicit API keys and prevents browser login from silently overriding them",
318 () =>
319 Effect.gen(function* () {
320 const harness = yield* makeHarness({ apiKey: " explicit-key " });
321 yield* Effect.promise(() => harness.store.save(harness.credentials));
322 expect(yield* harness.auth.requireApiKey).toBe("explicit-key");
323 expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true);
324 expect(
325 Exit.isFailure(yield* Effect.exit(harness.auth.controller.logout(Effect.void))),
326 ).toBe(true);
327 expect(harness.loginOptions()).toBeUndefined();
328 expect((yield* Effect.promise(() => harness.store.load()))?.apiKey).toBe("browser-key");
329 }).pipe(Effect.scoped),
330 );
331
332 it.effect("ignores expired browser credentials and rejects disabled sign-in", () =>
333 Effect.gen(function* () {
334 const harness = yield* makeHarness({ enabled: false });
335 yield* Effect.promise(() =>
336 harness.store.save({ ...harness.credentials, apiKeyExpiresAtMs: 0 }),
337 );
338 expect(yield* harness.auth.readApiKey).toBeUndefined();
339 expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true);
340 }).pipe(Effect.scoped),
341 );
342
343 it.effect("aborts pending login when the provider instance is disposed", () =>
344 Effect.gen(function* () {
345 const scope = yield* Scope.make();
346 const harness = yield* makeHarness().pipe(Effect.provideService(Scope.Scope, scope));
347 yield* harness.auth.controller.start(owner);
348 yield* phase(harness.auth, "waiting");
349 yield* Scope.close(scope, Exit.void);
350 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
351 yield* Effect.sync(() => harness.complete.resolve());
352 yield* Effect.promise(() => harness.returned.promise);
353 expect(yield* harness.auth.readApiKey).toBeUndefined();
354 }),
355 );
356});
apps/server/src/provider/CursorAuth.ts deleted-495
...@@ -1,495 +0,0 @@
1import type { SdkCredentialStore, SdkLoginOptions, SdkLoginResult } from "@cursor/sdk";
2import {
3 ProviderSetupError,
4 type ProviderAuthState,
5 type ProviderInstanceId,
6} from "@t3tools/contracts";
7import * as Clock from "effect/Clock";
8import * as Crypto from "effect/Crypto";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";
13import * as Queue from "effect/Queue";
14import * as Scope from "effect/Scope";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18
19import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts";
20import type { ProviderAuthController } from "./ProviderAuthService.ts";
21
22const AUTH_TIMEOUT_MS = 300_000;
23
24interface AuthFlow {
25 readonly id: string;
26 readonly owner: string;
27 fiber?: Fiber.Fiber<void>;
28}
29
30export interface CursorAuth {
31 readonly controller: ProviderAuthController;
32 readonly readApiKey: Effect.Effect<string | undefined, ProviderSetupError>;
33 readonly requireApiKey: Effect.Effect<string, ProviderSetupError>;
34 readonly usesApiKey: boolean;
35 readonly withAccess: <A, E, R>(
36 task: Effect.Effect<A, E, R>,
37 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
38}
39
40export interface CursorAuthOptions {
41 readonly instanceId: ProviderInstanceId;
42 readonly displayName: string;
43 readonly enabled: boolean;
44 readonly apiKey?: string;
45 readonly store: SdkCredentialStore;
46 readonly credentialBinding?: NonNullable<ProviderAuthController["credentialBinding"]>;
47 readonly onChanged: (signedIn: boolean) => Effect.Effect<void, ProviderSetupError>;
48 readonly login?: (options: SdkLoginOptions) => Promise<SdkLoginResult>;
49}
50
51/** Each instance owns its browser credential; explicit API keys always take precedence. */
52export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) {
53 const scope = yield* Scope.Scope;
54 const crypto = yield* Crypto.Crypto;
55 const lock = yield* Semaphore.make(1);
56 const configuredKey = options.apiKey?.trim() || undefined;
57 const emptyState: ProviderAuthState = {
58 instanceId: options.instanceId,
59 phase: "idle",
60 flowId: null,
61 authorizationUrl: null,
62 expiresAt: null,
63 message: null,
64 };
65 const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: emptyState });
66 let active: AuthFlow | undefined;
67 let operation: "idle" | "login" | "stopping" | "closed" = "idle";
68 const sessions = new Set<Scope.Closeable>();
69
70 const publish = (flow: AuthFlow, patch: Partial<ProviderAuthState>) =>
71 SubscriptionRef.update(snapshot, (current) => ({
72 owner: flow.owner,
73 state: { ...current.state, ...patch },
74 }));
75
76 const readApiKey = Effect.gen(function* () {
77 if (configuredKey) return configuredKey;
78 const credentials = yield* Effect.tryPromise({
79 try: () => options.store.load(),
80 catch: (cause) =>
81 new ProviderSetupError({
82 instanceId: options.instanceId,
83 operation: "credentials",
84 detail: "Could not read the Cursor sign-in. Try signing in again.",
85 cause,
86 }),
87 });
88 const now = yield* Clock.currentTimeMillis;
89 return credentials &&
90 (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now)
91 ? credentials.apiKey
92 : undefined;
93 });
94
95 const requireApiKey = Effect.gen(function* () {
96 if (operation !== "idle") {
97 return yield* new ProviderSetupError({
98 instanceId: options.instanceId,
99 operation: "credentials",
100 detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.",
101 });
102 }
103 const apiKey = yield* readApiKey;
104 if (!apiKey) {
105 return yield* new ProviderSetupError({
106 instanceId: options.instanceId,
107 operation: "credentials",
108 detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
109 });
110 }
111 return apiKey;
112 });
113
114 // Keep SDK sessions and one-shot text generation inside instance-owned scopes.
115 // Closing admission before draining these scopes prevents cached credentials surviving sign-out.
116 const withAccess: CursorAuth["withAccess"] = (task) =>
117 Effect.uninterruptibleMask((restore) =>
118 Effect.gen(function* () {
119 const parent = yield* Scope.Scope;
120 const child = yield* lock.withPermits(1)(
121 Effect.gen(function* () {
122 if (operation !== "idle")
123 return yield* new ProviderSetupError({
124 instanceId: options.instanceId,
125 operation: "session",
126 detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.",
127 });
128 const child = yield* Scope.make();
129 sessions.add(child);
130 yield* Scope.addFinalizer(
131 child,
132 Effect.sync(() => {
133 sessions.delete(child);
134 }),
135 );
136 yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void));
137 return child;
138 }),
139 );
140 const fiber = yield* restore(task).pipe(
141 Effect.provideService(Scope.Scope, child),
142 Effect.forkIn(child),
143 );
144 return yield* restore(Fiber.await(fiber)).pipe(
145 Effect.flatMap((result) => result),
146 Effect.onExit((result) =>
147 Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void,
148 ),
149 );
150 }),
151 );
152 const stopSessionsWithCredentials = Effect.suspend(() =>
153 Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), {
154 discard: true,
155 concurrency: "unbounded",
156 }),
157 );
158
159 const runLogin = (flow: AuthFlow, stopSessions: Effect.Effect<void, ProviderSetupError>) =>
160 Effect.gen(function* () {
161 yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials));
162 // The SDK first writes into memory. Cancellation must not save a late login result.
163 const pendingStore = new InMemoryCredentialStore();
164 const urls = yield* Queue.unbounded<string>();
165 yield* Queue.take(urls).pipe(
166 Effect.flatMap((authorizationUrl) =>
167 lock.withPermits(1)(
168 Effect.gen(function* () {
169 if (active !== flow) return;
170 yield* publish(flow, {
171 phase: "waiting",
172 authorizationUrl,
173 message:
174 "Open the Cursor sign-in page and finish signing in. This page updates automatically.",
175 });
176 }),
177 ),
178 ),
179 Effect.forever,
180 Effect.forkScoped,
181 );
182 yield* Effect.tryPromise({
183 try: (signal) =>
184 (options.login ?? Cursor.auth.login)({
185 openBrowser: false,
186 store: pendingStore,
187 signal,
188 apiKeyName: `T3 Code - ${options.displayName}`,
189 onLoginUrl: (authorizationUrl) => {
190 if (active === flow) Queue.offerUnsafe(urls, authorizationUrl);
191 },
192 }),
193 catch: (cause) =>
194 new ProviderSetupError({
195 instanceId: options.instanceId,
196 operation: "start",
197 detail: "Cursor sign-in failed. Start sign-in again.",
198 cause,
199 }),
200 });
201 // Commit and publish together; interrupting a store Promise cannot stop a late write.
202 yield* lock.withPermits(1)(
203 Effect.gen(function* () {
204 if (active !== flow) return;
205 yield* publish(flow, {
206 phase: "verifying",
207 authorizationUrl: null,
208 message: "Checking Cursor sign-in.",
209 });
210 yield* Effect.tryPromise({
211 try: async () => {
212 const credentials = await pendingStore.load();
213 if (!credentials) throw new Error("Cursor login did not return credentials");
214 await options.store.save(credentials);
215 },
216 catch: (cause) =>
217 new ProviderSetupError({
218 instanceId: options.instanceId,
219 operation: "start",
220 detail: "Could not save the Cursor sign-in. Try again.",
221 cause,
222 }),
223 });
224 const verified = yield* options.onChanged(true).pipe(Effect.exit);
225 if (Exit.isFailure(verified)) {
226 yield* Effect.tryPromise({
227 try: () => options.store.clear(),
228 catch: (cause) =>
229 new ProviderSetupError({
230 instanceId: options.instanceId,
231 operation: "start",
232 detail: "Could not clear the rejected Cursor sign-in. Try signing out.",
233 cause,
234 }),
235 });
236 return yield* Effect.failCause(verified.cause);
237 }
238 active = undefined;
239 operation = "idle";
240 yield* publish(flow, {
241 phase: "succeeded",
242 authorizationUrl: null,
243 expiresAt: null,
244 message: "Signed in with Cursor.",
245 });
246 }).pipe(Effect.uninterruptible),
247 );
248 }).pipe(
249 Effect.scoped,
250 Effect.timeoutOrElse({
251 duration: AUTH_TIMEOUT_MS,
252 orElse: () =>
253 Effect.fail(
254 new ProviderSetupError({
255 instanceId: options.instanceId,
256 operation: "start",
257 detail: "Cursor sign-in expired. Start sign-in again.",
258 }),
259 ),
260 }),
261 Effect.exit,
262 Effect.flatMap((result) =>
263 lock.withPermits(1)(
264 Effect.gen(function* () {
265 if (active !== flow) return;
266 active = undefined;
267 operation = "idle";
268 yield* publish(flow, {
269 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
270 authorizationUrl: null,
271 expiresAt: null,
272 message: Exit.isSuccess(result)
273 ? "Signed in with Cursor."
274 : "Cursor sign-in failed or expired. Start sign-in again.",
275 });
276 }),
277 ),
278 ),
279 );
280
281 const requireFlow = (owner: string, id: string, name: string) =>
282 Effect.gen(function* () {
283 if (!active || active.owner !== owner || active.id !== id) {
284 return yield* new ProviderSetupError({
285 instanceId: options.instanceId,
286 operation: name,
287 detail: "This sign-in is no longer active in this client.",
288 });
289 }
290 return active;
291 });
292
293 const controller: ProviderAuthController = {
294 credentialBinding: options.credentialBinding ?? {
295 owner: "t3",
296 key: `cursor:${options.instanceId}`,
297 },
298 isChangingCredentials: Effect.sync(() => operation !== "idle"),
299 start: (owner, stopSessions = Effect.void) =>
300 lock.withPermits(1)(
301 Effect.uninterruptible(
302 Effect.gen(function* () {
303 if (!options.enabled)
304 return yield* new ProviderSetupError({
305 instanceId: options.instanceId,
306 operation: "start",
307 detail: "Enable Cursor before signing in.",
308 });
309 if (configuredKey)
310 return yield* new ProviderSetupError({
311 instanceId: options.instanceId,
312 operation: "start",
313 detail:
314 "Remove CURSOR_API_KEY from this provider's environment before using browser sign-in.",
315 });
316 if (active?.owner === owner && operation === "login") return snapshot.value.state;
317 if (operation !== "idle")
318 return yield* new ProviderSetupError({
319 instanceId: options.instanceId,
320 operation: "start",
321 detail: "Cursor setup is already in progress.",
322 });
323 const id = yield* crypto.randomUUIDv4.pipe(
324 Effect.mapError(
325 (cause) =>
326 new ProviderSetupError({
327 instanceId: options.instanceId,
328 operation: "start",
329 detail: "Could not start Cursor sign-in. Try again.",
330 cause,
331 }),
332 ),
333 );
334 const expiresAt = DateTime.formatIso(
335 DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS),
336 );
337 const flow: AuthFlow = { id, owner };
338 active = flow;
339 operation = "login";
340 const state: ProviderAuthState = {
341 ...emptyState,
342 phase: "starting",
343 flowId: id,
344 expiresAt,
345 message: "Starting Cursor sign-in.",
346 };
347 yield* SubscriptionRef.set(snapshot, { owner, state });
348 flow.fiber = yield* runLogin(flow, stopSessions).pipe(
349 Effect.interruptible,
350 Effect.forkIn(scope),
351 );
352 return state;
353 }),
354 ),
355 ),
356 complete: () =>
357 Effect.fail(
358 new ProviderSetupError({
359 instanceId: options.instanceId,
360 operation: "complete",
361 detail: "Finish signing in on the Cursor website. No redirect URL is needed.",
362 }),
363 ),
364 cancel: (owner, id) =>
365 Effect.uninterruptible(
366 Effect.gen(function* () {
367 const flow = yield* lock.withPermits(1)(
368 Effect.gen(function* () {
369 const flow = yield* requireFlow(owner, id, "cancel");
370 active = undefined;
371 operation = "stopping";
372 yield* publish(flow, {
373 phase: "cancelled",
374 authorizationUrl: null,
375 expiresAt: null,
376 message: "Cursor sign-in was cancelled.",
377 });
378 return flow;
379 }),
380 );
381 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
382 operation = "idle";
383 return snapshot.value.state;
384 }),
385 ),
386 logout: (stopSessions) =>
387 Effect.uninterruptible(
388 Effect.gen(function* () {
389 const flow = yield* lock.withPermits(1)(
390 Effect.gen(function* () {
391 if (configuredKey)
392 return yield* new ProviderSetupError({
393 instanceId: options.instanceId,
394 operation: "logout",
395 detail:
396 "Remove CURSOR_API_KEY from this provider's environment to disconnect it.",
397 });
398 if (operation !== "idle" && operation !== "login")
399 return yield* new ProviderSetupError({
400 instanceId: options.instanceId,
401 operation: "logout",
402 detail: "Cursor setup is already stopping.",
403 });
404 operation = "stopping";
405 const flow = active;
406 active = undefined;
407 return flow;
408 }),
409 );
410 const result = yield* Effect.gen(function* () {
411 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
412 yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials));
413 yield* Effect.tryPromise({
414 try: () => options.store.clear(),
415 catch: (cause) =>
416 new ProviderSetupError({
417 instanceId: options.instanceId,
418 operation: "logout",
419 detail: "Could not clear the Cursor sign-in. Try again.",
420 cause,
421 }),
422 });
423 yield* options.onChanged(false);
424 }).pipe(Effect.exit);
425 operation = "idle";
426 const state: ProviderAuthState = {
427 ...emptyState,
428 phase: Exit.isSuccess(result) ? "idle" : "failed",
429 message: Exit.isSuccess(result)
430 ? "Signed out of Cursor."
431 : "Cursor sign-out failed. Try again.",
432 };
433 yield* SubscriptionRef.set(snapshot, { owner: null, state });
434 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
435 return state;
436 }),
437 ),
438 subscribe: (owner) =>
439 SubscriptionRef.changes(snapshot).pipe(
440 Stream.map((current) => {
441 const state = {
442 ...current.state,
443 credentialOwner: "t3" as const,
444 methods: [
445 {
446 id: "browser",
447 name: "Sign in with Cursor",
448 description: null,
449 type: "agent" as const,
450 },
451 ],
452 interaction:
453 current.state.phase === "waiting" &&
454 current.state.authorizationUrl &&
455 current.state.flowId
456 ? {
457 type: "browser" as const,
458 id: current.state.flowId,
459 url: current.state.authorizationUrl,
460 requiresConsent: false,
461 }
462 : null,
463 };
464 if (current.owner === null || current.owner === owner) return state;
465 return {
466 ...state,
467 interaction: null,
468 flowId: null,
469 authorizationUrl: null,
470 expiresAt: null,
471 message: active ? "Sign-in is in progress in another client." : current.state.message,
472 };
473 }),
474 ),
475 isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout",
476 };
477
478 yield* Effect.addFinalizer(() =>
479 Effect.gen(function* () {
480 operation = "closed";
481 const flow = active;
482 active = undefined;
483 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
484 yield* stopSessionsWithCredentials;
485 }),
486 );
487
488 return {
489 controller,
490 readApiKey,
491 requireApiKey,
492 withAccess,
493 usesApiKey: configuredKey !== undefined,
494 } satisfies CursorAuth;
495});
apps/server/src/provider/CursorCredentialStore.test.ts deleted-141
...@@ -1,141 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Option from "effect/Option";
8
9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import { makeCursorCredentialStore } from "./CursorCredentialStore.ts";
11
12const makeSecrets = () => {
13 const data = new Map<string, Uint8Array>();
14 return {
15 data,
16 secrets: ServerSecretStore.ServerSecretStore.of({
17 get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
18 set: (key, bytes) =>
19 Effect.sync(() => {
20 data.set(key, bytes);
21 }),
22 remove: (key) =>
23 Effect.sync(() => {
24 data.delete(key);
25 }),
26 create: () => Effect.die("unused"),
27 getOrCreateRandom: () => Effect.die("unused"),
28 }),
29 };
30};
31
32const legacyCredentials = {
33 version: 1 as const,
34 backendUrl: "https://api.cursor.com",
35 apiKey: "test-only-legacy-key",
36 createdAtMs: 100,
37};
38// The SDK's FileCredentialStore writes pretty-printed JSON.
39const legacyFileText = `{
40 "version": 1,
41 "backendUrl": "https://api.cursor.com",
42 "apiKey": "test-only-legacy-key",
43 "createdAtMs": 100
44}`;
45
46it.effect.each([
47 {
48 name: "imports a legacy sign-in",
49 legacy: legacyFileText,
50 stored: undefined,
51 expected: "test-only-legacy-key",
52 },
53 {
54 name: "keeps a newer stored sign-in",
55 legacy: legacyFileText,
56 stored: legacyFileText.replace("test-only-legacy-key", "test-only-current-key"),
57 expected: "test-only-current-key",
58 },
59 {
60 name: "replaces a damaged stored sign-in",
61 legacy: legacyFileText,
62 stored: "damaged",
63 expected: "test-only-legacy-key",
64 },
65 {
66 name: "discards a damaged legacy file",
67 legacy: "damaged",
68 stored: undefined,
69 expected: undefined,
70 },
71])("$name and deletes the legacy file", ({ legacy, stored, expected }) =>
72 Effect.gen(function* () {
73 const fileSystem = yield* FileSystem.FileSystem;
74 const path = yield* Path.Path;
75 const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json");
76 yield* fileSystem.writeFileString(legacyFile, legacy);
77 const { secrets } = makeSecrets();
78 const instanceId = ProviderInstanceId.make("personal");
79 if (stored !== undefined) {
80 const current = yield* makeCursorCredentialStore(instanceId).pipe(
81 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
82 );
83 yield* secrets.set(current.binding.key, new TextEncoder().encode(stored));
84 }
85 const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile).pipe(
86 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
87 );
88 assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected);
89 assert.isFalse(yield* fileSystem.exists(legacyFile));
90 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
91);
92
93it.effect(
94 "restores SDK credentials in a new controller and keeps another account when signing out",
95 () =>
96 Effect.gen(function* () {
97 const data = new Map<string, Uint8Array>();
98 const secrets = ServerSecretStore.ServerSecretStore.of({
99 get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))),
100 set: (key, bytes) =>
101 Effect.sync(() => {
102 data.set(key, bytes);
103 }),
104 remove: (key) =>
105 Effect.sync(() => {
106 data.delete(key);
107 }),
108 create: () => Effect.die("unused"),
109 getOrCreateRandom: () => Effect.die("unused"),
110 });
111 const makeStore = (id: string) =>
112 makeCursorCredentialStore(ProviderInstanceId.make(id)).pipe(
113 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
114 );
115 const personal = yield* makeStore("personal");
116 const work = yield* makeStore("work");
117 const credentials = {
118 version: 1 as const,
119 backendUrl: "https://api.cursor.com",
120 apiKey: "test-only-key",
121 createdAtMs: 100,
122 apiKeyExpiresAtMs: 1000,
123 email: "test@example.com",
124 };
125 yield* Effect.tryPromise(() => personal.store.save(credentials));
126 yield* Effect.tryPromise(() =>
127 work.store.save({ ...credentials, apiKey: "test-only-work-key" }),
128 );
129 const restored = yield* makeStore("personal");
130 assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials);
131 data.set(personal.binding.key, new TextEncoder().encode("damaged credential"));
132 assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load()));
133 yield* Effect.tryPromise(() => restored.store.save(credentials));
134 yield* Effect.tryPromise(() => restored.store.clear());
135 assert.isUndefined(yield* Effect.tryPromise(() => personal.store.load()));
136 assert.strictEqual(
137 (yield* Effect.tryPromise(() => work.store.load()))?.apiKey,
138 "test-only-work-key",
139 );
140 }).pipe(Effect.provide(NodeServices.layer)),
141);
apps/server/src/provider/CursorCredentialStore.ts deleted-91
...@@ -1,91 +0,0 @@
1import type { SdkCredentialStore } from "@cursor/sdk";
2import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
9
10const Credentials = Schema.fromJsonString(
11 Schema.Struct({
12 version: Schema.Literal(1),
13 backendUrl: Schema.String,
14 apiKey: Schema.String,
15 createdAtMs: Schema.Finite,
16 apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite),
17 email: Schema.optionalKey(Schema.String),
18 }),
19);
20
21const decodeCredentials = Schema.decodeUnknownEffect(Credentials);
22const encodeCredentials = Schema.encodeEffect(Credentials);
23
24/**
25 * The SDK owns the credential format; persistence uses the environment's secret store.
26 * `legacyFile` is the SDK file store earlier versions used; its sign-in moves into
27 * the secret store once and the file is deleted.
28 */
29export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* (
30 instanceId: ProviderInstanceId,
31 legacyFile?: string,
32) {
33 const credentials = yield* ProviderCredentialStore.make("cursor", instanceId);
34 if (legacyFile !== undefined) {
35 const fileSystem = yield* FileSystem.FileSystem;
36 yield* Effect.gen(function* () {
37 const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option);
38 if (Option.isNone(legacy)) return;
39 // A stored sign-in wins unless it is damaged; the SDK would ignore it anyway.
40 const stored = yield* credentials.get;
41 const storedIsValid =
42 Option.isSome(stored) &&
43 Option.isSome(
44 yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe(Effect.option),
45 );
46 if (
47 !storedIsValid &&
48 Option.isSome(yield* decodeCredentials(legacy.value).pipe(Effect.option))
49 ) {
50 yield* credentials.set(new TextEncoder().encode(legacy.value));
51 }
52 yield* fileSystem.remove(legacyFile);
53 }).pipe(
54 Effect.catch((error) =>
55 Effect.logWarning("Could not migrate legacy Cursor credentials", error),
56 ),
57 );
58 }
59 const store: SdkCredentialStore = {
60 load: () =>
61 Effect.runPromise(
62 credentials.get.pipe(
63 Effect.flatMap(
64 Option.match({
65 onNone: () => Effect.succeed(undefined),
66 onSome: (bytes) =>
67 decodeCredentials(new TextDecoder().decode(bytes)).pipe(
68 Effect.orElseSucceed(() => undefined),
69 ),
70 }),
71 ),
72 ),
73 ),
74 save: (value) =>
75 Effect.runPromise(
76 encodeCredentials(value).pipe(
77 Effect.mapError(
78 () =>
79 new ProviderSetupError({
80 instanceId,
81 operation: "credentials",
82 detail: "Cursor returned an unsupported credential format.",
83 }),
84 ),
85 Effect.flatMap((encoded) => credentials.set(new TextEncoder().encode(encoded))),
86 ),
87 ),
88 clear: () => Effect.runPromise(credentials.remove),
89 };
90 return { store, binding: credentials.binding };
91});
apps/server/src/provider/CursorProvider.test.ts deleted-252
...@@ -1,252 +0,0 @@
1import type { SDKModel } from "@cursor/sdk";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import type { CursorSettings } from "@t3tools/contracts";
6import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts";
7import { createModelCapabilities } from "@t3tools/shared/model";
8
9import {
10 buildCursorCapabilitiesFromSdkModel,
11 buildCursorDiscoveredModelsFromSdk,
12 buildCursorProviderSnapshot,
13 buildInitialCursorProviderSnapshot,
14 checkCursorProviderStatus,
15} from "./CursorProvider.ts";
16import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
17
18const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema);
19
20function selectDescriptor(
21 id: string,
22 label: string,
23 options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>,
24) {
25 return {
26 id,
27 label,
28 type: "select" as const,
29 options: [...options],
30 ...(options.find((option) => option.isDefault)?.id
31 ? { currentValue: options.find((option) => option.isDefault)?.id }
32 : {}),
33 };
34}
35
36function booleanDescriptor(id: string, label: string, currentValue?: boolean) {
37 return {
38 id,
39 label,
40 type: "boolean" as const,
41 ...(typeof currentValue === "boolean" ? { currentValue } : {}),
42 };
43}
44
45const baseCursorSettings: CursorSettings = decodeCursorSettings({
46 enabled: true,
47 customModels: [],
48});
49
50const sdkParameterizedModel = {
51 id: "claude-opus-4-8",
52 displayName: "Opus 4.8",
53 parameters: [
54 {
55 id: "thinking",
56 displayName: "Thinking",
57 values: [{ value: "false" }, { value: "true" }],
58 },
59 {
60 id: "context",
61 displayName: "Context",
62 values: [
63 { value: "300k", displayName: "300K" },
64 { value: "1m", displayName: "1M" },
65 ],
66 },
67 {
68 id: "effort",
69 displayName: "Effort",
70 values: [
71 { value: "low", displayName: "Low" },
72 { value: "high", displayName: "High" },
73 ],
74 },
75 {
76 id: "fast",
77 displayName: "Fast",
78 values: [{ value: "false" }, { value: "true", displayName: "Fast" }],
79 },
80 ],
81 variants: [
82 {
83 displayName: "Opus 4.8",
84 isDefault: true,
85 params: [
86 { id: "thinking", value: "true" },
87 { id: "context", value: "1m" },
88 { id: "effort", value: "high" },
89 { id: "fast", value: "false" },
90 ],
91 },
92 ],
93} satisfies SDKModel;
94
95describe("buildInitialCursorProviderSnapshot", () => {
96 it.effect("uses SDK-specific pending status copy", () =>
97 Effect.gen(function* () {
98 const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings);
99
100 expect(provider).toMatchObject({
101 status: "warning",
102 message: "Checking Cursor SDK availability...",
103 });
104 }),
105 );
106});
107
108describe("buildCursorProviderSnapshot", () => {
109 it("downgrades ready status to warning when SDK model discovery returns no models", () => {
110 expect(
111 buildCursorProviderSnapshot({
112 checkedAt: "2026-01-01T00:00:00.000Z",
113 cursorSettings: baseCursorSettings,
114 parsed: {
115 version: null,
116 status: "ready",
117 auth: { status: "authenticated", type: "api-key", label: "Cursor API key" },
118 },
119 discoveryWarning: "Cursor SDK model discovery returned no built-in models.",
120 }),
121 ).toMatchObject({
122 status: "warning",
123 message: "Cursor SDK model discovery returned no built-in models.",
124 models: [],
125 supportsConversationRollback: false,
126 });
127 });
128});
129
130describe("Cursor SDK model discovery", () => {
131 it("maps native SDK parameter ids and default variant values to model capabilities", () => {
132 expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual(
133 createModelCapabilities({
134 optionDescriptors: [
135 selectDescriptor("effort", "Effort", [
136 { id: "low", label: "Low" },
137 { id: "high", label: "High", isDefault: true },
138 ]),
139 selectDescriptor("contextWindow", "Context", [
140 { id: "300k", label: "300K" },
141 { id: "1m", label: "1M", isDefault: true },
142 ]),
143 booleanDescriptor("fastMode", "Fast", false),
144 booleanDescriptor("thinking", "Thinking", true),
145 ],
146 }),
147 );
148 });
149
150 it("filters invalid and duplicate SDK model entries", () => {
151 expect(
152 buildCursorDiscoveredModelsFromSdk([
153 sdkParameterizedModel,
154 { ...sdkParameterizedModel, displayName: "Duplicate" },
155 { id: "", displayName: "Invalid" },
156 ]),
157 ).toEqual([
158 {
159 slug: "claude-opus-4-8",
160 name: "Opus 4.8",
161 isCustom: false,
162 capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel),
163 },
164 ]);
165 });
166});
167
168describe("checkCursorProviderStatus", () => {
169 it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () =>
170 Effect.gen(function* () {
171 const provider = yield* checkCursorProviderStatus(
172 {
173 ...baseCursorSettings,
174 customModels: ["internal/cursor-model"],
175 },
176 { CURSOR_API_KEY: "test-cursor-key" },
177 ).pipe(
178 Effect.provide(
179 CursorSdkCatalog.layerTest((apiKey) => {
180 expect(apiKey).toBe("test-cursor-key");
181 return Effect.succeed({
182 user: {
183 apiKeyName: "test-key",
184 userEmail: "cursor@example.com",
185 createdAt: "2026-01-01T00:00:00.000Z",
186 },
187 models: [sdkParameterizedModel],
188 });
189 }),
190 ),
191 );
192
193 expect(provider).toMatchObject({
194 status: "ready",
195 auth: {
196 status: "authenticated",
197 type: "api-key",
198 label: "Cursor API key (test-key)",
199 email: "cursor@example.com",
200 },
201 models: [
202 { slug: "claude-opus-4-8", isCustom: false },
203 { slug: "internal/cursor-model", isCustom: true },
204 ],
205 });
206 }),
207 );
208
209 it.effect("surfaces SDK authentication failures", () =>
210 Effect.gen(function* () {
211 const provider = yield* checkCursorProviderStatus(baseCursorSettings, {
212 CURSOR_API_KEY: "invalid-test-key",
213 }).pipe(
214 Effect.provide(
215 CursorSdkCatalog.layerTest(() =>
216 Effect.fail(
217 new CursorSdkCatalog.CursorSdkCatalogError({
218 authenticationFailure: true,
219 cause: new Error("unauthorized"),
220 }),
221 ),
222 ),
223 ),
224 );
225
226 expect(provider).toMatchObject({
227 status: "error",
228 auth: { status: "unauthenticated" },
229 message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.",
230 });
231 }),
232 );
233
234 it.effect("requires a Cursor API key without probing any external Cursor binary", () =>
235 Effect.gen(function* () {
236 const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe(
237 Effect.provide(
238 CursorSdkCatalog.layerTest(() =>
239 Effect.die("SDK catalog must not be used without CURSOR_API_KEY"),
240 ),
241 ),
242 );
243
244 expect(provider).toMatchObject({
245 installed: true,
246 status: "error",
247 auth: { status: "unauthenticated" },
248 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
249 });
250 }),
251 );
252});
apps/server/src/provider/CursorProvider.ts deleted-350
...@@ -1,350 +0,0 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import type {
3 CursorSettings,
4 ModelCapabilities,
5 ProviderOptionDescriptor,
6 ServerProviderAuth,
7 ServerProviderModel,
8 ServerProviderState,
9} from "@t3tools/contracts";
10import { createModelCapabilities } from "@t3tools/shared/model";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Result from "effect/Result";
15
16import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./cursorSdkModel.ts";
17import {
18 buildBooleanOptionDescriptor,
19 buildSelectOptionDescriptor,
20 buildServerProvider,
21 providerModelsFromSettings,
22 type ServerProviderDraft,
23} from "@t3tools/provider-core/server/snapshotProbe";
24import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
25
26const CURSOR_PRESENTATION = {
27 displayName: "Cursor",
28 supportsConversationRollback: false,
29 showInteractionModeToggle: true,
30} as const;
31const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
32 optionDescriptors: [],
33});
34
35const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000;
36
37export function buildInitialCursorProviderSnapshot(
38 cursorSettings: CursorSettings,
39): Effect.Effect<ServerProviderDraft> {
40 return Effect.gen(function* () {
41 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
42 const models = getCursorFallbackModels(cursorSettings);
43
44 if (!cursorSettings.enabled) {
45 return buildServerProvider({
46 presentation: CURSOR_PRESENTATION,
47 enabled: false,
48 checkedAt,
49 models,
50 probe: {
51 installed: false,
52 version: null,
53 status: "warning",
54 auth: { status: "unknown" },
55 message: "Cursor is disabled in T3 Code settings.",
56 },
57 });
58 }
59
60 return buildServerProvider({
61 presentation: CURSOR_PRESENTATION,
62 enabled: true,
63 checkedAt,
64 models,
65 probe: {
66 installed: true,
67 version: null,
68 status: "warning",
69 auth: { status: "unknown" },
70 message: "Checking Cursor SDK availability...",
71 },
72 });
73 });
74}
75
76function getCursorFallbackModels(
77 cursorSettings: Pick<CursorSettings, "customModels">,
78): ReadonlyArray<ServerProviderModel> {
79 return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES);
80}
81
82function toTitleCaseWords(value: string): string {
83 const parts: Array<string> = [];
84 for (const part of value.split(/[\s_-]+/g)) {
85 if (part.length > 0) {
86 parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase());
87 }
88 }
89 return parts.join(" ");
90}
91
92function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined {
93 return model.variants
94 ?.find((variant) => variant.isDefault)
95 ?.params.find((parameter) => parameter.id === parameterId)?.value;
96}
97
98export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities {
99 const seen = new Set<string>();
100 const optionDescriptors: Array<ProviderOptionDescriptor> = [];
101 const parameters = (model.parameters ?? [])
102 .map((parameter, index) => ({ parameter, index }))
103 .toSorted(
104 (left, right) =>
105 cursorSdkParameterPriority(left.parameter.id) -
106 cursorSdkParameterPriority(right.parameter.id) || left.index - right.index,
107 );
108 for (const { parameter } of parameters) {
109 const nativeId = parameter.id.trim();
110 const id = cursorSdkProviderOptionId(nativeId);
111 if (!nativeId || !id || seen.has(id)) {
112 continue;
113 }
114 seen.add(id);
115
116 const values = parameter.values.flatMap((entry) => {
117 const value = entry.value.trim();
118 if (!value) {
119 return [];
120 }
121 return [
122 {
123 value,
124 label: entry.displayName?.trim() || value,
125 },
126 ];
127 });
128 if (values.length === 0) {
129 continue;
130 }
131
132 const label = parameter.displayName?.trim() || toTitleCaseWords(id);
133 const defaultValue = cursorSdkDefaultParameterValue(model, nativeId);
134 const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase()));
135 if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) {
136 if (defaultValue === "true" || defaultValue === "false") {
137 optionDescriptors.push(
138 buildBooleanOptionDescriptor({
139 id,
140 label,
141 currentValue: defaultValue === "true",
142 }),
143 );
144 } else {
145 optionDescriptors.push(buildBooleanOptionDescriptor({ id, label }));
146 }
147 continue;
148 }
149
150 optionDescriptors.push(
151 buildSelectOptionDescriptor({
152 id,
153 label,
154 options: values.map((entry) => ({
155 ...entry,
156 ...(entry.value === defaultValue ? { isDefault: true } : {}),
157 })),
158 }),
159 );
160 }
161
162 return createModelCapabilities({ optionDescriptors });
163}
164
165export function buildCursorDiscoveredModelsFromSdk(
166 models: ReadonlyArray<SDKModel>,
167): ReadonlyArray<ServerProviderModel> {
168 const seen = new Set<string>();
169 return models.flatMap((model) => {
170 const slug = model.id.trim();
171 const name = model.displayName.trim();
172 if (!slug || !name || seen.has(slug)) {
173 return [];
174 }
175 seen.add(slug);
176 return [
177 {
178 slug,
179 name,
180 isCustom: false,
181 capabilities: buildCursorCapabilitiesFromSdkModel(model),
182 } satisfies ServerProviderModel,
183 ];
184 });
185}
186
187function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth {
188 const email = user.userEmail?.trim();
189 const apiKeyName = user.apiKeyName.trim();
190 return {
191 status: "authenticated",
192 type,
193 label:
194 type === "browser"
195 ? "Cursor account"
196 : apiKeyName
197 ? `Cursor API key (${apiKeyName})`
198 : "Cursor API key",
199 ...(email ? { email } : {}),
200 };
201}
202
203interface CursorProviderProbeResult {
204 readonly version: string | null;
205 readonly status: Exclude<ServerProviderState, "disabled">;
206 readonly auth: ServerProviderAuth;
207 readonly message?: string;
208}
209
210function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined {
211 const parts: Array<string> = [];
212 for (const message of messages) {
213 const trimmed = message?.trim();
214 if (trimmed) {
215 parts.push(trimmed);
216 }
217 }
218 return parts.length > 0 ? parts.join(" ") : undefined;
219}
220
221export function buildCursorProviderSnapshot(input: {
222 readonly checkedAt: string;
223 readonly cursorSettings: CursorSettings;
224 readonly parsed: CursorProviderProbeResult;
225 readonly discoveredModels?: ReadonlyArray<ServerProviderModel>;
226 readonly discoveryWarning?: string;
227}): ServerProviderDraft {
228 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
229 return buildServerProvider({
230 presentation: CURSOR_PRESENTATION,
231 enabled: input.cursorSettings.enabled,
232 checkedAt: input.checkedAt,
233 models: providerModelsFromSettings(
234 input.discoveredModels ?? [],
235 input.cursorSettings.customModels,
236 EMPTY_CAPABILITIES,
237 ),
238 probe: {
239 installed: true,
240 version: input.parsed.version,
241 status:
242 input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status,
243 auth: input.parsed.auth,
244 ...(message ? { message } : {}),
245 },
246 });
247}
248
249export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* (
250 cursorSettings: CursorSettings,
251 environment?: NodeJS.ProcessEnv,
252 authenticationType: "api-key" | "browser" = "api-key",
253): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> {
254 const checkedAt = DateTime.formatIso(yield* DateTime.now);
255 const fallbackModels = getCursorFallbackModels(cursorSettings);
256
257 if (!cursorSettings.enabled) {
258 return buildServerProvider({
259 presentation: CURSOR_PRESENTATION,
260 enabled: false,
261 checkedAt,
262 models: fallbackModels,
263 probe: {
264 installed: false,
265 version: null,
266 status: "warning",
267 auth: { status: "unknown" },
268 message: "Cursor is disabled in T3 Code settings.",
269 },
270 });
271 }
272
273 const sdkApiKey = environment?.CURSOR_API_KEY?.trim();
274 if (!sdkApiKey) {
275 return buildServerProvider({
276 presentation: CURSOR_PRESENTATION,
277 enabled: cursorSettings.enabled,
278 checkedAt,
279 models: fallbackModels,
280 probe: {
281 installed: true,
282 version: null,
283 status: "error",
284 auth: { status: "unauthenticated" },
285 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
286 },
287 });
288 }
289
290 const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog;
291 const catalogResult = yield* sdkCatalog
292 .read(sdkApiKey)
293 .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result);
294
295 if (Result.isFailure(catalogResult)) {
296 yield* Effect.logWarning("Cursor SDK catalog probe failed", {
297 cause: catalogResult.failure.cause,
298 });
299 const authenticationFailure = catalogResult.failure.authenticationFailure;
300 return buildServerProvider({
301 presentation: CURSOR_PRESENTATION,
302 enabled: cursorSettings.enabled,
303 checkedAt,
304 models: fallbackModels,
305 probe: {
306 installed: true,
307 version: null,
308 status: "error",
309 auth: { status: authenticationFailure ? "unauthenticated" : "unknown" },
310 message: authenticationFailure
311 ? authenticationType === "browser"
312 ? "Cursor sign-in expired or was rejected. Sign in again in provider settings."
313 : "Cursor SDK authentication failed. Check CURSOR_API_KEY."
314 : "Cursor SDK catalog request failed. Check server logs for details.",
315 },
316 });
317 }
318
319 if (Option.isNone(catalogResult.success)) {
320 return buildServerProvider({
321 presentation: CURSOR_PRESENTATION,
322 enabled: cursorSettings.enabled,
323 checkedAt,
324 models: fallbackModels,
325 probe: {
326 installed: true,
327 version: null,
328 status: "error",
329 auth: { status: "unknown" },
330 message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`,
331 },
332 });
333 }
334
335 const snapshot = catalogResult.success.value;
336 const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models);
337 return buildCursorProviderSnapshot({
338 checkedAt,
339 cursorSettings,
340 parsed: {
341 version: null,
342 status: "ready",
343 auth: cursorSdkAuth(snapshot.user, authenticationType),
344 },
345 discoveredModels,
346 ...(discoveredModels.length === 0
347 ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." }
348 : {}),
349 });
350});
apps/server/src/provider/CursorSdkCatalog.test.ts deleted-66
...@@ -1,66 +0,0 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5
6import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
7
8const user = {
9 apiKeyName: "test-key",
10 userEmail: "cursor@example.com",
11 createdAt: "2026-01-01T00:00:00.000Z",
12} satisfies SDKUser;
13
14const model = {
15 id: "claude-opus-4-8",
16 displayName: "Opus 4.8",
17 parameters: [],
18} satisfies SDKModel;
19
20const catalogError = () =>
21 new CursorSdkCatalog.CursorSdkCatalogError({
22 authenticationFailure: false,
23 cause: new Error("catalog unavailable"),
24 });
25
26describe("CursorSdkCatalog", () => {
27 it.effect("keeps the user probe fresh while caching models by API key", () =>
28 Effect.gen(function* () {
29 let userCalls = 0;
30 let modelCalls = 0;
31 const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
32 readUser: () => Effect.sync(() => ((userCalls += 1), user)),
33 readModels: () => Effect.sync(() => ((modelCalls += 1), [model])),
34 });
35
36 yield* catalog.read("first-key");
37 yield* catalog.read("first-key");
38 yield* catalog.read("second-key");
39
40 expect(userCalls).toBe(3);
41 expect(modelCalls).toBe(2);
42 }),
43 );
44
45 it.effect("does not cache failed or empty model discovery", () =>
46 Effect.gen(function* () {
47 let modelCalls = 0;
48 const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
49 readUser: () => Effect.succeed(user),
50 readModels: () =>
51 Effect.suspend(() => {
52 modelCalls += 1;
53 if (modelCalls === 1) return Effect.fail(catalogError());
54 if (modelCalls === 2) return Effect.succeed([]);
55 return Effect.succeed([model]);
56 }),
57 });
58
59 expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true);
60 expect((yield* catalog.read("test-key")).models).toEqual([]);
61 expect((yield* catalog.read("test-key")).models).toEqual([model]);
62 expect((yield* catalog.read("test-key")).models).toEqual([model]);
63 expect(modelCalls).toBe(3);
64 }),
65 );
66});
apps/server/src/provider/CursorSdkCatalog.ts deleted-92
...@@ -1,92 +0,0 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9
10import { AuthenticationError, Cursor, CursorSdkError } from "./cursorSdk.ts";
11
12export interface CursorSdkCatalogSnapshot {
13 readonly user: SDKUser;
14 readonly models: ReadonlyArray<SDKModel>;
15}
16
17export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()(
18 "CursorSdkCatalogError",
19 {
20 authenticationFailure: Schema.Boolean,
21 cause: Schema.Defect(),
22 },
23) {
24 override get message(): string {
25 return this.authenticationFailure
26 ? "Cursor SDK authentication failed."
27 : "Cursor SDK catalog request failed.";
28 }
29}
30
31export interface CursorSdkCatalogShape {
32 readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>;
33}
34
35export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()(
36 "t3/provider/CursorSdkCatalog",
37) {}
38
39function isAuthenticationFailure(cause: unknown): boolean {
40 return (
41 cause instanceof AuthenticationError ||
42 (cause instanceof CursorSdkError && cause.status === 401)
43 );
44}
45
46export interface CursorSdkCatalogProbes {
47 readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>;
48 readonly readModels: (
49 apiKey: string,
50 ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>;
51}
52
53const sdkRequest = <A>(tryRequest: () => Promise<A>) =>
54 Effect.tryPromise({
55 try: tryRequest,
56 catch: (cause) =>
57 new CursorSdkCatalogError({
58 authenticationFailure: isAuthenticationFailure(cause),
59 cause,
60 }),
61 });
62
63const liveProbes: CursorSdkCatalogProbes = {
64 readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })),
65 readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })),
66};
67
68export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* (
69 probes: CursorSdkCatalogProbes = liveProbes,
70) {
71 const modelCache = yield* Cache.makeWith(probes.readModels, {
72 capacity: 32,
73 timeToLive: (exit) =>
74 Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
75 });
76 return CursorSdkCatalog.of({
77 read: (apiKey) =>
78 Effect.all(
79 {
80 user: probes.readUser(apiKey),
81 models: Cache.get(modelCache, apiKey),
82 },
83 { concurrency: "unbounded" },
84 ),
85 });
86});
87
88export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog());
89
90export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> {
91 return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read }));
92}
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+5-1
...@@ -34,6 +34,7 @@ import * as ModelManifest from "../ModelManifest.ts";...@@ -34,6 +34,7 @@ import * as ModelManifest from "../ModelManifest.ts";
34import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";34import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
35import { AntigravityDriver } from "./AntigravityDriver.ts";35import { AntigravityDriver } from "./AntigravityDriver.ts";
36import * as ProviderHostLive from "../ProviderHostLive.ts";36import * as ProviderHostLive from "../ProviderHostLive.ts";
37import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
3738
38const hostPlatform = HostProcessPlatform.defaultValue();39const hostPlatform = HostProcessPlatform.defaultValue();
39const windowsHost = hostPlatform === "win32";40const windowsHost = hostPlatform === "win32";
...@@ -264,7 +265,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {...@@ -264,7 +265,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {
264 Layer.provideMerge(ModelManifest.layerTest),265 Layer.provideMerge(ModelManifest.layerTest),
265 Layer.provideMerge(IdAllocator.layer),266 Layer.provideMerge(IdAllocator.layer),
266);267);
267const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps));268const layerTest = ProviderHostLive.layer.pipe(
269 Layer.provideMerge(ServerSecretStore.layer),
270 Layer.provideMerge(layerDeps),
271);
268272
269it.layer(layerTest)("AntigravityDriver", (it) => {273it.layer(layerTest)("AntigravityDriver", (it) => {
270 it.effect.skipIf(windowsHost)(274 it.effect.skipIf(windowsHost)(
apps/server/src/provider/Drivers/CursorDriver.test.ts deleted-185
...@@ -1,185 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { expect, it } from "@effect/vitest";
5import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Stream from "effect/Stream";
10import { vi } from "vite-plus/test";
11import { HttpClient } from "effect/http";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13
14import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
15import * as ServerConfig from "../../config.ts";
16import * as ServerSettings from "../../serverSettings.ts";
17import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
18import { CursorDriver } from "./CursorDriver.ts";
19import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts";
20import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
21import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";
22import { Cursor } from "../cursorSdk.ts";
23import * as ProviderHostLive from "../ProviderHostLive.ts";
24
25const layerDeps = ServerSecretStore.layer.pipe(
26 Layer.provideMerge(
27 ServerConfig.layerTest(process.cwd(), {
28 prefix: "t3-cursor-driver-copy-command-",
29 }),
30 ),
31 Layer.provideMerge(NodeServices.layer),
32 Layer.provideMerge(IdAllocator.layer),
33 Layer.provideMerge(
34 Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({
35 open: () => Effect.die("Maintenance resolution must not open a Cursor session"),
36 }),
37 ),
38 Layer.provideMerge(ServerSettings.layerTest()),
39 Layer.provideMerge(
40 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
41 shouldRunScopeWork: () => Effect.succeed(false),
42 }),
43 ),
44 Layer.provideMerge(
45 Layer.succeed(
46 ProviderEventLoggers.ProviderEventLoggers,
47 ProviderEventLoggers.NoOpProviderEventLoggers,
48 ),
49 ),
50 Layer.provideMerge(
51 Layer.succeed(
52 HttpClient.HttpClient,
53 HttpClient.make(() => Effect.die("Disabled Cursor must not make an HTTP request")),
54 ),
55 ),
56);
57const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps));
58
59it.layer(layerTest)("CursorDriver", (it) => {
60 it.effect(
61 "persists browser credentials, uses them for chat, and closes the SDK session on logout",
62 () =>
63 Effect.gen(function* () {
64 const login = vi.spyOn(Cursor.auth, "login").mockImplementation(async (options) => {
65 options?.onLoginUrl?.("https://cursor.com/loginDeepControl?challenge=test-only");
66 await options?.store?.save({
67 version: 1,
68 backendUrl: "https://api2.cursor.sh",
69 apiKey: "instance-browser-key",
70 createdAtMs: 0,
71 apiKeyExpiresAtMs: 4_000_000_000_000,
72 email: "cursor@example.com",
73 });
74 return { apiKey: "instance-browser-key", apiKeyExpiresAtMs: 4_000_000_000_000 };
75 });
76 const me = vi.spyOn(Cursor, "me").mockResolvedValue({
77 apiKeyName: "T3 Code",
78 createdAt: "2026-01-01T00:00:00.000Z",
79 userEmail: "cursor@example.com",
80 });
81 const models = vi
82 .spyOn(Cursor.models, "list")
83 .mockResolvedValue([{ id: "auto", displayName: "Auto" }]);
84 yield* Effect.addFinalizer(() =>
85 Effect.sync(() => {
86 login.mockRestore();
87 me.mockRestore();
88 models.mockRestore();
89 }),
90 );
91 const input = {
92 instanceId: ProviderInstanceId.make("cursor-browser-persisted"),
93 displayName: "Personal Cursor",
94 enabled: true,
95 environment: [{ name: "CURSOR_API_KEY", value: "", sensitive: true }],
96 config: CursorDriver.defaultConfig(),
97 };
98 const openedKeys: Array<string | undefined> = [];
99 let closed = 0;
100 const instance = yield* CursorDriver.create(input).pipe(
101 Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
102 assertComplete: Effect.void,
103 open: (request) =>
104 Effect.sync(() => {
105 openedKeys.push(request.options.apiKey);
106 return {
107 agentId: "browser-auth-agent",
108 listMessages: Effect.succeed([]),
109 send: () => Effect.die("This test only opens a session"),
110 close: Effect.sync(() => {
111 closed += 1;
112 }),
113 };
114 }),
115 }),
116 );
117 expect((yield* instance.snapshot.refresh).auth.status).toBe("unauthenticated");
118 yield* instance.auth!.start("client");
119 yield* instance.auth!.subscribe("client").pipe(
120 Stream.filter((state) => state.phase === "succeeded"),
121 Stream.runHead,
122 Effect.map(Option.getOrThrow),
123 );
124 expect(me).toHaveBeenCalledWith({ apiKey: "instance-browser-key" });
125 expect(yield* instance.snapshot.getSnapshot).toMatchObject({
126 status: "ready",
127 auth: {
128 status: "authenticated",
129 type: "browser",
130 canLogout: true,
131 email: "cursor@example.com",
132 },
133 setup: { canAuthenticate: true, canInstall: false },
134 });
135 const threadId = ThreadId.make("cursor-browser-thread");
136 const modelSelection = { instanceId: input.instanceId, model: "auto" };
137 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
138 runtimeMode: "full-access",
139 interactionMode: "default",
140 cwd: process.cwd(),
141 });
142 const runtime = yield* instance.orchestrationAdapter.openSession({
143 threadId,
144 providerSessionId: ProviderSessionId.make("cursor-browser-session"),
145 modelSelection,
146 runtimePolicy,
147 });
148 yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy });
149 expect(openedKeys).toEqual(["instance-browser-key"]);
150 expect(closed).toBe(0);
151 const recreated = yield* CursorDriver.create(input);
152 expect((yield* recreated.snapshot.refresh).auth.status).toBe("authenticated");
153 yield* instance.auth!.logout(Effect.void);
154 expect(closed).toBe(1);
155 expect((yield* instance.snapshot.getSnapshot).auth.status).toBe("unauthenticated");
156 expect((yield* recreated.snapshot.refresh).auth.status).toBe("unauthenticated");
157 expect(
158 (yield* Effect.exit(runtime.ensureThread({ threadId, modelSelection, runtimePolicy })))
159 ._tag,
160 ).toBe("Failure");
161 expect(openedKeys).toEqual(["instance-browser-key"]);
162 expect(closed).toBe(1);
163 }).pipe(Effect.scoped),
164 );
165
166 it.effect("keeps the bundled SDK manual-only without probing or updating cursor-agent", () =>
167 Effect.gen(function* () {
168 const instance = yield* CursorDriver.create({
169 instanceId: ProviderInstanceId.make("cursor-sdk"),
170 displayName: "Cursor test",
171 enabled: false,
172 environment: [],
173 config: CursorDriver.defaultConfig(),
174 });
175 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
176 expect((yield* instance.snapshot.refresh).status).toBe("disabled");
177 }).pipe(
178 Effect.provideService(
179 ChildProcessSpawner.ChildProcessSpawner,
180 ChildProcessSpawner.make(() => Effect.die("SDK maintenance must not spawn a process")),
181 ),
182 Effect.scoped,
183 ),
184 );
185});
apps/server/src/provider/Drivers/CursorDriver.ts deleted-291
...@@ -1,291 +0,0 @@
1/**
2 * CursorDriver — `ProviderDriver` for the Cursor Agent SDK runtime.
3 *
4 * Provider status, model discovery, orchestration, and text generation use the
5 * official Cursor SDK with an instance browser login or CURSOR_API_KEY.
6 *
7 * @module provider/Drivers/CursorDriver
8 */
9import { CursorSettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Crypto from "effect/Crypto";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import * as Schema from "effect/Schema";
15import { HttpClient } from "effect/http";
16import { readCursorUsageLimits } from "../cursorUsageLimits.ts";
17
18import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
19import * as ServerConfig from "../../config.ts";
20import * as ServerSettings from "../../serverSettings.ts";
21import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts";
22import {
23 CursorAdapterV2Driver,
24 type CursorAdapterV2DriverEnv,
25} from "../../orchestration-v2/Adapters/CursorAdapterV2.ts";
26import { ProviderDriverError } from "../Errors.ts";
27import {
28 buildInitialCursorProviderSnapshot,
29 checkCursorProviderStatus,
30} from "../CursorProvider.ts";
31import * as CursorSdkCatalog from "../CursorSdkCatalog.ts";
32import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
33import {
34 defaultProviderContinuationIdentity,
35 type ProviderDriver,
36 type ProviderInstance,
37} from "@t3tools/provider-core/server/driver";
38import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
39import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
40import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver";
41import {
42 haveProviderSnapshotSettingsChanged,
43 makeProviderSnapshotSettingsSource,
44 type ProviderSnapshotSettings,
45} from "@t3tools/provider-core/server/snapshotSettings";
46import { probeCursorSkills } from "./CursorSkills.ts";
47import { makeCursorAuth } from "../CursorAuth.ts";
48import * as CursorCredentialStore from "../CursorCredentialStore.ts";
49import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
50import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts";
51const decodeCursorSettings = Schema.decodeSync(CursorSettings);
52const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError);
53
54const DRIVER_KIND = ProviderDriverKind.make("cursor");
55const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({
56 provider: DRIVER_KIND,
57 packageName: null,
58});
59
60export type CursorDriverEnv =
61 | CursorAdapterV2DriverEnv
62 | Crypto.Crypto
63 | FileSystem.FileSystem
64 | Path.Path
65 | HttpClient.HttpClient
66 | ProviderHost
67 | ServerConfig.ServerConfig
68 | ServerSecretStore.ServerSecretStore
69 | ServerSettings.ServerSettingsService;
70
71export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
72 driverKind: DRIVER_KIND,
73 metadata: {
74 displayName: "Cursor",
75 supportsMultipleInstances: true,
76 },
77 configSchema: CursorSettings,
78 defaultConfig: (): CursorSettings => decodeCursorSettings({}),
79 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
80 Effect.gen(function* () {
81 const serverSettings = yield* ServerSettings.ServerSettingsService;
82 const host = yield* ProviderHost;
83 const fileSystem = yield* FileSystem.FileSystem;
84 const path = yield* Path.Path;
85 const httpClient = yield* HttpClient.HttpClient;
86 const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner;
87 const processEnv = mergeProviderInstanceEnvironment(environment);
88 const continuationIdentity = defaultProviderContinuationIdentity({
89 driverKind: DRIVER_KIND,
90 instanceId,
91 });
92 const stampIdentity = withInstanceIdentity({
93 instanceId,
94 driverKind: DRIVER_KIND,
95 displayName: displayName ?? "Cursor",
96 accentColor,
97 continuationGroupKey: continuationIdentity.continuationKey,
98 });
99 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;
100 const credentials = yield* CursorCredentialStore.makeCursorCredentialStore(
101 instanceId,
102 path.join(
103 (yield* ServerConfig.ServerConfig).stateDir,
104 "provider-auth",
105 encodeURIComponent(instanceId),
106 "cursor.json",
107 ),
108 ).pipe(
109 Effect.mapError(
110 (cause) =>
111 new ProviderDriverError({
112 driver: DRIVER_KIND,
113 instanceId,
114 detail: "Could not open the Cursor credential store.",
115 cause,
116 }),
117 ),
118 );
119 const auth = yield* makeCursorAuth({
120 instanceId,
121 displayName: displayName ?? "Cursor",
122 enabled,
123 ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}),
124 store: credentials.store,
125 credentialBinding: credentials.binding,
126 onChanged: (signedIn): Effect.Effect<void, ProviderSetupError> =>
127 snapshot.refresh.pipe(
128 Effect.flatMap((provider) =>
129 !signedIn || provider.auth.status === "authenticated"
130 ? Effect.void
131 : Effect.fail(
132 new ProviderSetupError({
133 instanceId,
134 operation: "start",
135 detail: provider.message ?? "Could not verify the Cursor sign-in. Try again.",
136 }),
137 ),
138 ),
139 ),
140 });
141 const stampSnapshot: typeof stampIdentity = (draft) =>
142 stampIdentity({
143 ...draft,
144 setup: { canAuthenticate: !auth.usesApiKey, canInstall: false },
145 auth: {
146 ...draft.auth,
147 canLogout: !auth.usesApiKey,
148 },
149 });
150
151 const orchestrationAdapter = yield* CursorAdapterV2Driver.create({
152 instanceId,
153 displayName,
154 accentColor,
155 environment,
156 enabled,
157 config,
158 }).pipe(
159 Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
160 ...sdkRunner,
161 open: (input) =>
162 auth.requireApiKey.pipe(
163 Effect.flatMap((apiKey) =>
164 Effect.acquireRelease(
165 sdkRunner
166 .open({ ...input, options: { ...input.options, apiKey } })
167 .pipe(
168 Effect.flatMap((session) =>
169 Effect.cached(session.close).pipe(
170 Effect.map((close) => ({ ...session, close })),
171 ),
172 ),
173 ),
174 (session) => session.close.pipe(Effect.ignore),
175 ),
176 ),
177 auth.withAccess,
178 Effect.mapError((cause) =>
179 isSdkRunnerError(cause)
180 ? cause
181 : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }),
182 ),
183 ),
184 }),
185 Effect.mapError(
186 (cause) =>
187 new ProviderDriverError({
188 driver: DRIVER_KIND,
189 instanceId,
190 detail: "Failed to build Cursor orchestration adapter.",
191 cause,
192 }),
193 ),
194 );
195 const textGeneration = yield* makeCursorTextGeneration(
196 effectiveConfig,
197 processEnv,
198 auth.requireApiKey,
199 auth.withAccess,
200 );
201
202 const checkProvider = auth.readApiKey.pipe(
203 Effect.orElseSucceed(() => undefined),
204 Effect.flatMap((apiKey) =>
205 checkCursorProviderStatus(
206 effectiveConfig,
207 {
208 ...processEnv,
209 CURSOR_API_KEY: apiKey,
210 },
211 auth.usesApiKey ? "api-key" : "browser",
212 ).pipe(
213 Effect.flatMap((snapshot) =>
214 effectiveConfig.enabled &&
215 snapshot.installed &&
216 snapshot.auth.status === "authenticated"
217 ? serverSettings.getSettings.pipe(
218 Effect.flatMap((settings) =>
219 readCursorUsageLimits(
220 effectiveConfig,
221 { ...processEnv, CURSOR_API_KEY: apiKey },
222 settings.cursorKeychainUsageEnabled,
223 ),
224 ),
225 Effect.map((usageLimits) => ({ ...snapshot, usageLimits })),
226 )
227 : Effect.succeed(snapshot),
228 ),
229 ),
230 ),
231 Effect.provideService(HttpClient.HttpClient, httpClient),
232 Effect.provideService(FileSystem.FileSystem, fileSystem),
233 Effect.provideService(Path.Path, path),
234 Effect.map(stampSnapshot),
235 Effect.provide(CursorSdkCatalog.layer),
236 );
237
238 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
239 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({
240 resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES),
241 getSettings: snapshotSettings.getSettings,
242 streamSettings: snapshotSettings.streamSettings,
243 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
244 initialSnapshot: (settings) =>
245 buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampSnapshot)),
246 checkProvider,
247 }).pipe(
248 Effect.mapError(
249 (cause) =>
250 new ProviderDriverError({
251 driver: DRIVER_KIND,
252 instanceId,
253 detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`,
254 cause,
255 }),
256 ),
257 );
258
259 return {
260 instanceId,
261 driverKind: DRIVER_KIND,
262 continuationIdentity,
263 displayName,
264 accentColor,
265 enabled,
266 auth: auth.controller,
267 snapshot,
268 snapshotForCwd: (cwd) =>
269 !effectiveConfig.enabled
270 ? snapshot.getSnapshot
271 : Effect.all([
272 snapshot.getSnapshot,
273 probeCursorSkills(cwd, processEnv).pipe(
274 Effect.provideService(FileSystem.FileSystem, fileSystem),
275 Effect.provideService(Path.Path, path),
276 Effect.mapError(
277 (cause) =>
278 new ProviderDriverError({
279 driver: DRIVER_KIND,
280 instanceId,
281 detail: `Failed to discover Cursor skills for '${cwd}'`,
282 cause,
283 }),
284 ),
285 ),
286 ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))),
287 orchestrationAdapter,
288 textGeneration,
289 } satisfies ProviderInstance;
290 }),
291};
apps/server/src/provider/Drivers/CursorSkills.test.ts deleted-186
...@@ -1,186 +0,0 @@
1import * as NodeOS from "node:os";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "vite-plus/test";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import type * as Scope from "effect/Scope";
8import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9
10import {
11 discoverCursorSkills,
12 hasCursorSkillMention,
13 probeCursorSkills,
14 rewriteCursorSkillMentions,
15} from "./CursorSkills.ts";
16
17const runNode = <A, E>(
18 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
19) => effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);
20
21describe("Cursor skills", () => {
22 it("discovers recursive project skills with project precedence", async () =>
23 await runNode(
24 Effect.gen(function* () {
25 const fileSystem = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const userHome = yield* fileSystem
28 .makeTempDirectoryScoped({
29 directory: NodeOS.tmpdir(),
30 prefix: "cursor-skills-home-",
31 })
32 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
33 const workspace = yield* fileSystem
34 .makeTempDirectoryScoped({
35 directory: NodeOS.tmpdir(),
36 prefix: "cursor-skills-workspace-",
37 })
38 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
39 const writeSkill = Effect.fn("writeCursorSkill")(function* (
40 root: string,
41 name: string,
42 contents: string,
43 ) {
44 const skillDirectory = path.join(root, name);
45 yield* fileSystem.makeDirectory(skillDirectory, { recursive: true });
46 yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents);
47 });
48
49 yield* writeSkill(
50 path.join(userHome, ".cursor", "skills"),
51 "review",
52 "---\ndescription: user review\n---\n",
53 );
54 yield* writeSkill(
55 path.join(workspace, ".agents", "skills", "nested"),
56 "review",
57 "---\nname: Review changes\ndescription: project review\n---\n",
58 );
59 yield* writeSkill(
60 path.join(workspace, ".cursor", "skills"),
61 "internal",
62 "---\nuser-invocable: false\n---\n",
63 );
64 yield* writeSkill(
65 path.join(workspace, ".cursor", "skills"),
66 "oversized",
67 "x".repeat(1_000_001),
68 );
69 yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true });
70 yield* fileSystem.writeFileString(
71 path.join(userHome, ".codex", "skills"),
72 "not a directory",
73 );
74
75 const skills = yield* discoverCursorSkills(workspace, { HOME: userHome });
76 expect(skills).toEqual([
77 {
78 name: "internal",
79 path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"),
80 scope: "project",
81 enabled: true,
82 userInvocable: false,
83 },
84 {
85 name: "oversized",
86 path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"),
87 scope: "project",
88 enabled: true,
89 },
90 {
91 name: "review",
92 displayName: "Review changes",
93 description: "project review",
94 path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"),
95 scope: "project",
96 enabled: true,
97 },
98 ]);
99 expect(
100 (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag,
101 ).toBe("Failure");
102 }),
103 ));
104
105 it.skipIf(!symlinksSupported)(
106 "treats a symlinked skill outside the root as a package boundary",
107 async () =>
108 await runNode(
109 Effect.gen(function* () {
110 const fileSystem = yield* FileSystem.FileSystem;
111 const path = yield* Path.Path;
112 const userHome = yield* fileSystem.makeTempDirectoryScoped({
113 directory: NodeOS.tmpdir(),
114 prefix: "cursor-skills-home-",
115 });
116 const workspace = yield* fileSystem
117 .makeTempDirectoryScoped({
118 directory: NodeOS.tmpdir(),
119 prefix: "cursor-skills-workspace-",
120 })
121 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
122 const library = yield* fileSystem.makeTempDirectoryScoped({
123 directory: NodeOS.tmpdir(),
124 prefix: "cursor-skills-library-",
125 });
126 const writeSkill = Effect.fn("writeCursorSkill")(function* (
127 directory: string,
128 contents: string,
129 ) {
130 yield* fileSystem.makeDirectory(directory, { recursive: true });
131 yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents);
132 });
133
134 // A skill package managed in a config repo and installed by symlink.
135 // Its own SKILL.md must be discovered under the link name, but nothing
136 // below the target may be walked.
137 yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n");
138 yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n");
139 const root = path.join(workspace, ".cursor", "skills");
140 yield* fileSystem.makeDirectory(root, { recursive: true });
141 yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review"));
142
143 const skills = yield* discoverCursorSkills(workspace, { HOME: userHome });
144 expect(skills).toEqual([
145 {
146 name: "review",
147 description: "shared",
148 path: path.join(root, "review", "SKILL.md"),
149 scope: "project",
150 enabled: true,
151 },
152 ]);
153 expect(
154 (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag,
155 ).toBe("Success");
156 }),
157 ),
158 );
159
160 it("rewrites only discovered skill mentions into Cursor slash invocations", () => {
161 expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true);
162 expect(hasCursorSkillMention("please $review this")).toBe(true);
163 expect(
164 rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])),
165 ).toBe("use $review, keep $HOME and 5$review");
166 expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe(
167 "please /review this",
168 );
169 });
170 it("detects and invokes digit-leading Cursor skills without rewriting money", () => {
171 const names = new Set(["2spec", "20k", "100M", "1e6"]);
172 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
173 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
174 expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here");
175 expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here");
176 for (const text of [
177 "pay $20 tomorrow",
178 "budget $20k here",
179 "cost $100M total",
180 "limit $1e6 here",
181 ]) {
182 expect(hasCursorSkillMention(text)).toBe(false);
183 expect(rewriteCursorSkillMentions(text, names)).toBe(text);
184 }
185 });
186});
apps/server/src/provider/Drivers/CursorSkills.ts deleted-290
...@@ -1,290 +0,0 @@
1/**
2 * CursorSkills — workspace-aware discovery and native invocation for Cursor.
3 *
4 * Cursor discovers Agent Skills recursively from user and project roots but
5 * its ACP command catalog only appears after opening a real session. Scanning
6 * the same roots avoids starting an agent and its MCP servers just to populate
7 * a composer menu.
8 *
9 * @module provider/Drivers/CursorSkills
10 */
11import * as NodeOS from "node:os";
12
13import type { ServerProviderSkill } from "@t3tools/contracts";
14import * as ByteSize from "effect/ByteSize";
15import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
17import * as Path from "effect/Path";
18import * as PlatformError from "effect/PlatformError";
19import * as Schema from "effect/Schema";
20import { parse as parseYamlDocument } from "yaml";
21
22const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
23const SKILL_MENTION_PATTERN =
24 /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;
25const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u");
26const MAX_SKILL_DEPTH = 10;
27const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000);
28const MAX_SKILL_SCAN_ENTRIES = 10_000;
29const MAX_SKILL_SCAN_BYTES = ByteSize.bytes(8_000_000);
30
31interface CursorSkillFrontmatter {
32 readonly description?: string;
33 readonly displayName?: string;
34 readonly userInvocationOnly?: boolean;
35 readonly userInvocable?: boolean;
36 readonly cliVisible: boolean;
37}
38
39interface CursorSkillScanBudget {
40 remainingEntries: number;
41 remainingBytes: bigint;
42 exhausted: boolean;
43 incomplete: boolean;
44}
45
46class CursorSkillsProbeError extends Schema.TaggedError<CursorSkillsProbeError>()(
47 "CursorSkillsProbeError",
48 {
49 reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]),
50 cwd: Schema.optional(Schema.String),
51 },
52) {
53 override get message(): string {
54 const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`;
55 return `Cursor skill discovery${location} was incomplete (${this.reason}).`;
56 }
57}
58
59const orUndefined = <A, R>(
60 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
61 budget?: CursorSkillScanBudget,
62): Effect.Effect<A | undefined, never, R> =>
63 effect.pipe(
64 Effect.map((value): A | undefined => value),
65 Effect.catchTags({
66 PlatformError: (error) => {
67 if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true;
68 return Effect.void.pipe(Effect.as(undefined));
69 },
70 }),
71 );
72
73function parseFrontmatterBoolean(value: unknown): boolean | undefined {
74 if (typeof value === "boolean") return value;
75 if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined;
76 if (typeof value !== "string") return undefined;
77 switch (value.trim().toLowerCase()) {
78 case "true":
79 case "yes":
80 case "on":
81 return true;
82 case "false":
83 case "no":
84 case "off":
85 return false;
86 default:
87 return undefined;
88 }
89}
90
91function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined {
92 const match = FRONTMATTER_PATTERN.exec(contents);
93 if (!match) return { cliVisible: true };
94
95 let parsed: unknown;
96 try {
97 parsed = parseYamlDocument(match[1] ?? "");
98 } catch {
99 return undefined;
100 }
101 if (typeof parsed !== "object" || parsed === null) return undefined;
102
103 const record = parsed as Record<string, unknown>;
104 const metadata =
105 typeof record.metadata === "object" && record.metadata !== null
106 ? (record.metadata as Record<string, unknown>)
107 : undefined;
108 const rawSurfaces = metadata?.surfaces;
109 const surfaces = Array.isArray(rawSurfaces)
110 ? rawSurfaces.filter((surface): surface is string => typeof surface === "string")
111 : typeof rawSurfaces === "string"
112 ? rawSurfaces.split(",")
113 : [];
114 const description = typeof record.description === "string" ? record.description.trim() : "";
115 const displayName = typeof record.name === "string" ? record.name.trim() : "";
116 return {
117 cliVisible:
118 surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"),
119 ...(description ? { description } : {}),
120 ...(displayName ? { displayName } : {}),
121 ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true
122 ? { userInvocationOnly: true }
123 : {}),
124 ...(parseFrontmatterBoolean(record["user-invocable"]) === false
125 ? { userInvocable: false }
126 : {}),
127 };
128}
129
130const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: {
131 readonly directory: string;
132 readonly scope: "user" | "project";
133 readonly budget: CursorSkillScanBudget;
134}): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
135 const fileSystem = yield* FileSystem.FileSystem;
136 const path = yield* Path.Path;
137 const skills: ServerProviderSkill[] = [];
138 if (input.budget.exhausted) return skills;
139 const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget);
140 if (!rootDirectory) return skills;
141 const visitedDirectories = new Set<string>();
142
143 const visit = Effect.fn("visitCursorSkillDirectory")(function* (
144 directory: string,
145 depth: number,
146 ): Effect.fn.Return<void, never> {
147 if (input.budget.exhausted) return;
148 const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget);
149 if (!resolvedDirectory) {
150 return;
151 }
152 if (visitedDirectories.has(resolvedDirectory)) {
153 return;
154 }
155 visitedDirectories.add(resolvedDirectory);
156 // A symlink whose target lives outside the root is a skill package
157 // boundary: read its own SKILL.md so linked skill libraries show up, but
158 // never walk the target tree.
159 const insideRoot =
160 resolvedDirectory === rootDirectory ||
161 resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`);
162
163 const skillPath = path.join(directory, "SKILL.md");
164 const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget);
165 if (skillInfo?.type === "File") {
166 let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true };
167 if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) {
168 const contents = yield* orUndefined(fileSystem.readFileString(skillPath));
169 if (contents !== undefined) {
170 input.budget.remainingBytes -= skillInfo.size;
171 frontmatter = parseSkillFrontmatter(contents);
172 }
173 }
174 const name = path.basename(directory).trim();
175 if (frontmatter?.cliVisible && name) {
176 skills.push({
177 name,
178 path: skillPath,
179 scope: input.scope,
180 enabled: true,
181 ...(frontmatter.displayName && frontmatter.displayName !== name
182 ? { displayName: frontmatter.displayName }
183 : {}),
184 ...(frontmatter.description ? { description: frontmatter.description } : {}),
185 ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}),
186 ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}),
187 });
188 }
189 }
190
191 if (!insideRoot) {
192 return;
193 }
194 const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget);
195 if (!entries) {
196 return;
197 }
198 for (const entry of [...entries].sort()) {
199 if (input.budget.remainingEntries === 0) {
200 input.budget.exhausted = true;
201 return;
202 }
203 input.budget.remainingEntries -= 1;
204 const child = path.join(directory, entry);
205 const info = yield* orUndefined(fileSystem.stat(child), input.budget);
206 if (info?.type !== "Directory") continue;
207 if (depth >= MAX_SKILL_DEPTH) {
208 input.budget.exhausted = true;
209 return;
210 }
211 yield* visit(child, depth + 1);
212 }
213 });
214
215 yield* visit(rootDirectory, 0);
216 return skills;
217});
218
219const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* (
220 cwd?: string,
221 environment: NodeJS.ProcessEnv = process.env,
222) {
223 const path = yield* Path.Path;
224 const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir();
225 const rootsBelow = (base: string, scope: "user" | "project") => [
226 { directory: path.join(base, ".cursor", "skills"), scope },
227 { directory: path.join(base, ".agents", "skills"), scope },
228 { directory: path.join(base, ".codex", "skills"), scope },
229 { directory: path.join(base, ".claude", "skills"), scope },
230 ];
231 const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")];
232
233 const skillsByName = new Map<string, ServerProviderSkill>();
234 const budget: CursorSkillScanBudget = {
235 remainingEntries: MAX_SKILL_SCAN_ENTRIES,
236 remainingBytes: MAX_SKILL_SCAN_BYTES,
237 exhausted: false,
238 incomplete: false,
239 };
240 for (const root of roots) {
241 if (budget.exhausted) break;
242 const skills = yield* discoverSkillsInRoot({ ...root, budget });
243 for (const skill of skills) {
244 if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill);
245 }
246 }
247 return {
248 skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)),
249 failureReason: budget.exhausted
250 ? ("scan-budget-exhausted" as const)
251 : budget.incomplete
252 ? ("filesystem-error" as const)
253 : undefined,
254 };
255});
256
257export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* (
258 cwd?: string,
259 environment: NodeJS.ProcessEnv = process.env,
260) {
261 return (yield* inspectCursorSkills(cwd, environment)).skills;
262});
263
264export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* (
265 cwd?: string,
266 environment: NodeJS.ProcessEnv = process.env,
267) {
268 const inspection = yield* inspectCursorSkills(cwd, environment);
269 if (inspection.failureReason) {
270 return yield* new CursorSkillsProbeError({
271 reason: inspection.failureReason,
272 ...(cwd ? { cwd } : {}),
273 });
274 }
275 return inspection.skills;
276});
277
278/** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */
279export function hasCursorSkillMention(prompt: string): boolean {
280 return HAS_SKILL_MENTION_PATTERN.test(prompt);
281}
282
283export function rewriteCursorSkillMentions(
284 prompt: string,
285 skillNames: ReadonlySet<string>,
286): string {
287 return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) =>
288 skillNames.has(name) ? `${prefix}/${name}` : match,
289 );
290}
apps/server/src/provider/Drivers/GrokDriver.test.ts+5-1
...@@ -17,6 +17,7 @@ import { GrokDriver } from "./GrokDriver.ts";...@@ -17,6 +17,7 @@ import { GrokDriver } from "./GrokDriver.ts";
1717
18import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";18import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
19import * as ProviderHostLive from "../ProviderHostLive.ts";19import * as ProviderHostLive from "../ProviderHostLive.ts";
20import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
2021
21const layerDeps = ServerConfig.layerTest(process.cwd(), {22const layerDeps = ServerConfig.layerTest(process.cwd(), {
22 prefix: "t3-grok-driver-update-",23 prefix: "t3-grok-driver-update-",
...@@ -42,7 +43,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {...@@ -42,7 +43,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {
42 ),43 ),
43 ),44 ),
44);45);
45const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps));46const layerTest = ProviderHostLive.layer.pipe(
47 Layer.provideMerge(ServerSecretStore.layer),
48 Layer.provideMerge(layerDeps),
49);
4650
47const noSpawner = ChildProcessSpawner.make(() =>51const noSpawner = ChildProcessSpawner.make(() =>
48 Effect.die("Disabled Grok must not spawn a process"),52 Effect.die("Disabled Grok must not spawn a process"),
apps/server/src/provider/ProviderHostLive.ts+30
...@@ -5,13 +5,17 @@...@@ -5,13 +5,17 @@
5 * @module provider/ProviderHostLive5 * @module provider/ProviderHostLive
6 */6 */
7import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";7import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
8import { ProviderCredentialError } from "@t3tools/provider-core/server/errors";
9import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
1012
11import { resolveAttachmentPath } from "../attachmentStore.ts";13import { resolveAttachmentPath } from "../attachmentStore.ts";
14import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";15import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
13import * as ServerConfig from "../config.ts";16import * as ServerConfig from "../config.ts";
14import * as ServerSettings from "../serverSettings.ts";17import * as ServerSettings from "../serverSettings.ts";
18import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
1519
16export const layer = Layer.effect(20export const layer = Layer.effect(
17 ProviderHost,21 ProviderHost,
...@@ -19,6 +23,8 @@ export const layer = Layer.effect(...@@ -19,6 +23,8 @@ export const layer = Layer.effect(
19 const config = yield* ServerConfig.ServerConfig;23 const config = yield* ServerConfig.ServerConfig;
20 const serverSettings = yield* ServerSettings.ServerSettingsService;24 const serverSettings = yield* ServerSettings.ServerSettingsService;
21 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;25 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
26 const secrets = yield* ServerSecretStore.ServerSecretStore;
27 const crypto = yield* Crypto.Crypto;
22 return ProviderHost.of({28 return ProviderHost.of({
23 paths: {29 paths: {
24 cwd: config.cwd,30 cwd: config.cwd,
...@@ -34,6 +40,30 @@ export const layer = Layer.effect(...@@ -34,6 +40,30 @@ export const layer = Layer.effect(
34 shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork,40 shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork,
35 resolveAttachmentPath: (attachment) =>41 resolveAttachmentPath: (attachment) =>
36 resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }),42 resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }),
43 credentials: (namespace, bindingId) =>
44 ProviderCredentialStore.make(namespace, bindingId).pipe(
45 Effect.map((store) => ({
46 binding: store.binding,
47 get: store.get.pipe(
48 Effect.mapError((cause) => new ProviderCredentialError({ operation: "get", cause })),
49 ),
50 set: (credentials: Uint8Array) =>
51 store
52 .set(credentials)
53 .pipe(
54 Effect.mapError(
55 (cause) => new ProviderCredentialError({ operation: "set", cause }),
56 ),
57 ),
58 remove: store.remove.pipe(
59 Effect.mapError(
60 (cause) => new ProviderCredentialError({ operation: "remove", cause }),
61 ),
62 ),
63 })),
64 Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
65 Effect.provideService(Crypto.Crypto, crypto),
66 ),
37 });67 });
38 }),68 }),
39);69);
apps/server/src/provider/ProviderInstanceRegistry.test.ts+2-2
...@@ -31,12 +31,12 @@ import {...@@ -31,12 +31,12 @@ import {
31 EnvironmentId,31 EnvironmentId,
32 type ClaudeSettings,32 type ClaudeSettings,
33 type CodexSettings,33 type CodexSettings,
34 type CursorSettings,
35 type GrokSettings,34 type GrokSettings,
36 ProviderDriverKind,35 ProviderDriverKind,
37 type ProviderInstanceConfigMap,36 type ProviderInstanceConfigMap,
38 ProviderInstanceId,37 ProviderInstanceId,
39} from "@t3tools/contracts";38} from "@t3tools/contracts";
39import type { CursorSettings } from "@t3tools/provider-cursor/settings";
40import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";40import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
41import * as DateTime from "effect/DateTime";41import * as DateTime from "effect/DateTime";
42import * as Effect from "effect/Effect";42import * as Effect from "effect/Effect";
...@@ -53,7 +53,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";...@@ -53,7 +53,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
53import * as ServerSettings from "../serverSettings.ts";53import * as ServerSettings from "../serverSettings.ts";
54import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";54import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
55import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";55import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
56import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";56import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server";
57import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";57import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
58import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";58import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";
59import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings";59import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings";
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts+1-1
...@@ -2,7 +2,7 @@ import * as Layer from "effect/Layer";...@@ -2,7 +2,7 @@ import * as Layer from "effect/Layer";
22
3import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";3import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
4import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts";4import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
5import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts";5import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk";
6import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";6import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
7import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";7import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
88
apps/server/src/provider/acp/CursorTransportFailure.test.ts deleted-60
...@@ -1,60 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { CursorTransportFailure } from "./CursorTransportFailure.ts";
4
5const diagnostic = "Error: RetriableError: WritableIterable is closed";
6
7function failureFor(chunks: string[]) {
8 const reply = new CursorTransportFailure();
9 for (const chunk of chunks) reply.push(chunk);
10 return reply.failure;
11}
12
13describe("CursorTransportFailure", () => {
14 it.each([
15 diagnostic,
16 "Error: ConnectError: [unavailable] transport closed",
17 "Error: ConnectError: [aborted] aborted",
18 "Error: ConnectError: [deadline_exceeded] timed out",
19 "Something went wrong communicating with the server. Please try again.",
20 ])("recognizes a terminal diagnostic: %s", (message) => {
21 expect(failureFor([message])).toBe(message);
22 expect(failureFor([...message])).toBe(message);
23 });
24
25 it("recognizes a standalone failure with a stack trace", () => {
26 expect(failureFor(["\n", diagnostic, "\n at send (cli.js:1:2)\n\n"])).toBe(diagnostic);
27 });
28
29 it.each([
30 "The error was " + diagnostic,
31 "This is an example of a transport error:\n" + diagnostic,
32 "I inspected the files.\n" + diagnostic,
33 "> " + diagnostic,
34 " " + diagnostic,
35 "```text\n" + diagnostic + "\n```",
36 "~~~\n" + diagnostic + "\n~~~",
37 diagnostic + "\nThis is an example of a transport error.",
38 "Error: ConnectError: [unauthenticated] sign in",
39 "Error: ConnectError: [permission_denied] subscription required",
40 "Error: HTTP 500 from the application being debugged",
41 "Error: RetriableError: [internal] Failed to run step, exceeded max retries",
42 "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)",
43 ])("preserves prose, code and non-transport errors: %s", (message) => {
44 expect(failureFor([...message])).toBeUndefined();
45 });
46
47 it("tracks fences across long lines without retaining the answer", () => {
48 expect(failureFor(["```\n", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
49 expect(failureFor(["```", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
50 expect(failureFor(["x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
51 });
52
53 it("does not retain a previous failure after new output", () => {
54 const reply = new CursorTransportFailure();
55 reply.push(diagnostic);
56 expect(reply.failure).toBe(diagnostic);
57 reply.push("\nRecovered successfully.");
58 expect(reply.failure).toBeUndefined();
59 });
60});
apps/server/src/provider/acp/CursorTransportFailure.ts deleted-52
...@@ -1,52 +0,0 @@
1const maxLineLength = 4096;
2// Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics.
3const transportError =
4 /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/;
5const serverError = "Something went wrong communicating with the server. Please try again.";
6
7interface ReplyState {
8 disqualified: boolean;
9 failure: string | undefined;
10}
11
12function consumeLine(state: ReplyState, line: string) {
13 if (state.disqualified) return;
14 const text = line.trimEnd();
15 if (transportError.test(text) || text === serverError) {
16 state.failure = text;
17 } else if (text.trim() !== "" && !(state.failure && /^\s+at\s/.test(text))) {
18 // An explanation or code sample can quote the same diagnostic. Only
19 // classify an assistant item consisting entirely of a transport dump.
20 state.disqualified = true;
21 state.failure = undefined;
22 }
23}
24
25/** Tracks a standalone Cursor diagnostic without retaining an entire streamed answer. */
26export class CursorTransportFailure {
27 private state: ReplyState = { disqualified: false, failure: undefined };
28 private line = "";
29
30 push(text: string) {
31 for (const [index, part] of text.split("\n").entries()) {
32 if (this.state.disqualified) return;
33 if (index > 0) {
34 consumeLine(this.state, this.line);
35 this.line = "";
36 }
37 if (this.line.length + part.length > maxLineLength) {
38 this.state.disqualified = true;
39 this.state.failure = undefined;
40 this.line = "";
41 return;
42 }
43 this.line += part;
44 }
45 }
46
47 get failure() {
48 const state = { ...this.state };
49 consumeLine(state, this.line);
50 return state.failure;
51 }
52}
apps/server/src/provider/builtInDrivers.ts+1-1
...@@ -24,7 +24,7 @@ import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegis...@@ -24,7 +24,7 @@ import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegis
24import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts";24import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts";
25import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";25import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
26import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";26import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
27import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";27import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server";
28import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";28import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
29import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";29import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";
30import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";30import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";
apps/server/src/provider/cursorKeychainToken.test.ts deleted-46
...@@ -1,46 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import {
4 CursorKeychainTimeoutError,
5 makeCachedCursorAccessTokenReader,
6} from "./cursorKeychainToken.ts";
7
8describe("Cursor Keychain reader", () => {
9 it("shares concurrent reads and rechecks after the cache expires", async () => {
10 let reads = 0;
11 let time = 0;
12 const read = makeCachedCursorAccessTokenReader(
13 async () => {
14 reads++;
15 return `token-${reads}`;
16 },
17 () => time,
18 );
19 assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]);
20 assert.strictEqual(await read(), "token-1");
21 assert.strictEqual(reads, 1);
22 time = 5 * 60_000;
23 assert.strictEqual(await read(), "token-2");
24 });
25
26 it("gives up on an unanswered prompt and reuses it on the next read", async () => {
27 let reads = 0;
28 let allow: (token: string) => void = () => {};
29 const read = makeCachedCursorAccessTokenReader(
30 () => {
31 reads++;
32 return new Promise((resolve) => {
33 allow = resolve;
34 });
35 },
36 () => 0,
37 1,
38 );
39 const error = await read().catch((cause: unknown) => cause);
40 assert.instanceOf(error, CursorKeychainTimeoutError);
41 const retry = read();
42 allow("token");
43 assert.strictEqual(await retry, "token");
44 assert.strictEqual(reads, 1);
45 });
46});
apps/server/src/provider/cursorKeychainToken.ts deleted-55
...@@ -1,55 +0,0 @@
1import * as NodeModule from "node:module";
2
3const CACHE_MS = 5 * 60_000;
4
5const requireForKeyring = NodeModule.createRequire(import.meta.url);
6
7/** Rejected when nobody answers the macOS Keychain prompt in time. */
8export class CursorKeychainTimeoutError extends Error {
9 constructor() {
10 super("Timed out waiting for Keychain access.");
11 }
12}
13
14/**
15 * Share one Keychain request across usage history and limits in this server process.
16 *
17 * macOS shows the access prompt on the server's own screen, which a remote
18 * client cannot answer, so callers give up after `timeoutMs`. The read stays in
19 * flight: the next call reuses it instead of stacking a second prompt, and picks
20 * up the token once someone allows access.
21 */
22export function makeCachedCursorAccessTokenReader(
23 read: () => Promise<string | null>,
24 now: () => number = Date.now,
25 timeoutMs = 30_000,
26): () => Promise<string | null> {
27 let cached: { token: string; until: number } | null = null;
28 let pending: Promise<string | null> | null = null;
29 return () => {
30 if (cached && cached.until > now()) return Promise.resolve(cached.token);
31 pending ??= read()
32 .then((token) => {
33 cached = token ? { token, until: now() + CACHE_MS } : null;
34 return token;
35 })
36 .finally(() => {
37 pending = null;
38 });
39 const deadline = AbortSignal.timeout(timeoutMs);
40 return Promise.race([
41 pending,
42 new Promise<never>((_, reject) => {
43 deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), {
44 once: true,
45 });
46 }),
47 ]);
48 };
49}
50
51/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */
52export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => {
53 const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring");
54 return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null;
55});
apps/server/src/provider/cursorSdk.test.ts deleted-193
...@@ -1,193 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- The probe is a plain Node child; this deadline kills it if it hangs.
2import * as NodeChildProcess from "node:child_process";
3import { describe, expect, it } from "vite-plus/test";
4
5const cursorSdkUrl = new URL("./cursorSdk.ts", import.meta.url).href;
6
7// Loads cursorSdk.ts in a fresh process. @cursor/sdk is stubbed so the guard
8// can be tested without the real package. The vitest worker already has its
9// own unhandledRejection listener, which would hide the process-exit behavior.
10const probeProgram = `
11import { mkdtempSync, writeFileSync } from "node:fs";
12import { tmpdir } from "node:os";
13import { join } from "node:path";
14import { registerHooks } from "node:module";
15import { pathToFileURL } from "node:url";
16import { spawn } from "node:child_process";
17
18const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-"));
19const stub = join(dir, "stub.cjs");
20writeFileSync(
21 stub,
22 "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };",
23);
24registerHooks({
25 resolve(specifier, context, next) {
26 if (specifier === "@cursor/sdk") {
27 return { url: pathToFileURL(stub).href, shortCircuit: true };
28 }
29 return next(specifier, context);
30 },
31});
32
33const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)});
34const mode = process.argv[1];
35const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid);
36
37if (mode === "predicate") {
38 const cursorShell = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
39 code: "ENOENT",
40 syscall: "spawn /bin/zsh",
41 path: "/bin/zsh",
42 spawnargs: ["-c", "dump_zsh_state >&4", "--", "true"],
43 });
44 const bashShell = Object.assign(new Error("spawn bash ENOENT"), {
45 code: "ENOENT",
46 syscall: "spawn bash",
47 spawnargs: ["-c", "dump_bash_state >&4"],
48 });
49 const sandboxRestore = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
50 code: "ENOENT",
51 syscall: "spawn /bin/zsh",
52 spawnargs: ["-c", "builtin eval \\"\${__CURSOR_SANDBOX_ENV_RESTORE:-}\\""],
53 });
54 const gitSpawn = Object.assign(new Error("spawn git ENOENT"), {
55 code: "ENOENT",
56 syscall: "spawn git",
57 path: "git",
58 spawnargs: ["status"],
59 });
60 const plainZsh = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
61 code: "ENOENT",
62 syscall: "spawn /bin/zsh",
63 path: "/bin/zsh",
64 spawnargs: ["-lc", "true"],
65 });
66 console.log(
67 JSON.stringify({
68 cursorShell: isCursorShellSpawnFailure(cursorShell),
69 bashShell: isCursorShellSpawnFailure(bashShell),
70 sandboxRestore: isCursorShellSpawnFailure(sandboxRestore),
71 gitSpawn: isCursorShellSpawnFailure(gitSpawn),
72 plainZsh: isCursorShellSpawnFailure(plainZsh),
73 open: isCursorShellSpawnFailure(
74 Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }),
75 ),
76 plain: isCursorShellSpawnFailure(new Error("boom")),
77 string: isCursorShellSpawnFailure("spawn ENOENT"),
78 }),
79 );
80 process.exit(0);
81}
82
83if (mode === "cursor-shell") {
84 const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], {
85 cwd: missingCwd,
86 });
87 child.on("error", (error) => {
88 Promise.reject(error);
89 });
90 setTimeout(() => process.exit(0), 500);
91} else if (mode === "other-spawn") {
92 Promise.reject(
93 Object.assign(new Error("spawn git ENOENT"), {
94 code: "ENOENT",
95 syscall: "spawn git",
96 path: "git",
97 spawnargs: ["status"],
98 }),
99 );
100 setTimeout(() => process.exit(0), 500);
101} else if (mode === "other-rejection") {
102 Promise.reject(new Error("boom"));
103 setTimeout(() => process.exit(0), 500);
104} else if (mode === "spawn-without-listener") {
105 spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd });
106 setTimeout(() => process.exit(0), 500);
107} else {
108 console.error("Unknown cursor shell spawn guard probe: " + mode);
109 process.exit(2);
110}
111`;
112
113function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
114 return new Promise((resolve, reject) => {
115 const child = NodeChildProcess.spawn(
116 process.execPath,
117 [
118 "--experimental-strip-types",
119 "--disable-warning=ExperimentalWarning",
120 "--input-type=module",
121 "-e",
122 probeProgram,
123 mode,
124 ],
125 { stdio: ["ignore", "pipe", "pipe"] },
126 );
127 let stdout = "";
128 let stderr = "";
129 child.stdout.setEncoding("utf8");
130 child.stderr.setEncoding("utf8");
131 child.stdout.on("data", (chunk: string) => {
132 stdout += chunk;
133 });
134 child.stderr.on("data", (chunk: string) => {
135 stderr += chunk;
136 });
137 const timer = setTimeout(() => {
138 child.kill();
139 reject(new Error(`probe ${mode} timed out`));
140 }, 5_000);
141 child.on("error", reject);
142 child.on("exit", (code) => {
143 clearTimeout(timer);
144 resolve({ code, stdout, stderr });
145 });
146 });
147}
148
149describe("isCursorShellSpawnFailure", () => {
150 it("matches only Cursor's shell wrapper", async () => {
151 const result = await runProbe("predicate");
152 expect(result.code).toBe(0);
153 expect(JSON.parse(result.stdout)).toEqual({
154 cursorShell: true,
155 bashShell: true,
156 sandboxRestore: true,
157 gitSpawn: false,
158 plainZsh: false,
159 open: false,
160 plain: false,
161 string: false,
162 });
163 });
164});
165
166describe("Cursor shell spawn guard", () => {
167 it("keeps the process alive when Cursor's shell spawn rejects", async () => {
168 const result = await runProbe("cursor-shell");
169 expect(result.code).toBe(0);
170 expect(result.stderr).toContain("The server will keep running.");
171 expect(result.stderr).toContain("ENOENT");
172 });
173
174 it("still exits when a different spawn failure is unhandled", async () => {
175 const result = await runProbe("other-spawn");
176 expect(result.code).toBe(1);
177 expect(result.stderr).toContain("spawn git ENOENT");
178 expect(result.stderr).not.toContain("The server will keep running.");
179 });
180
181 it("still exits on unrelated unhandled rejections", async () => {
182 const result = await runProbe("other-rejection");
183 expect(result.code).toBe(1);
184 expect(result.stderr).toContain("boom");
185 });
186
187 it("leaves a spawn with no error listener fatal", async () => {
188 const result = await runProbe("spawn-without-listener");
189 expect(result.code).toBe(1);
190 expect(result.stderr).toContain("Unhandled");
191 expect(result.stderr).not.toContain("The server will keep running.");
192 });
193});
apps/server/src/provider/cursorSdk.ts deleted-77
...@@ -1,77 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Installed before the SDK loads, outside an Effect runtime. stderr must match Node's default unhandled-rejection print.
2import * as NodeModule from "node:module";
3
4/**
5 * The Cursor Agent SDK runs in this process and spawns a shell for tool
6 * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`,
7 * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory
8 * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error,
9 * then rejects an internal promise nothing awaits. Node exits on that
10 * unhandled rejection and takes the server with it.
11 *
12 * This listener ignores only that rejection. Every other unhandled rejection
13 * still prints and exits when this is the process's only listener, matching
14 * Node's default. `child_process.spawn` itself is left alone, so git,
15 * terminals, and other providers keep their own error handling.
16 */
17const CURSOR_SHELL_SPAWN_MARKERS = [
18 "dump_zsh_state",
19 "dump_bash_state",
20 "__CURSOR_SANDBOX_ENV_RESTORE",
21] as const;
22
23export function isCursorShellSpawnFailure(reason: unknown): boolean {
24 if (typeof reason !== "object" || reason === null) {
25 return false;
26 }
27 const syscall = Reflect.get(reason, "syscall");
28 const code = Reflect.get(reason, "code");
29 const spawnargs = Reflect.get(reason, "spawnargs");
30 if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") {
31 return false;
32 }
33 if (!Array.isArray(spawnargs)) {
34 return false;
35 }
36 return spawnargs.some(
37 (arg) =>
38 typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)),
39 );
40}
41
42let cursorShellSpawnGuardInstalled = false;
43
44function installCursorShellSpawnGuard(): void {
45 if (cursorShellSpawnGuardInstalled) {
46 return;
47 }
48 cursorShellSpawnGuardInstalled = true;
49
50 process.on("unhandledRejection", (reason) => {
51 if (isCursorShellSpawnFailure(reason)) {
52 console.error("Cursor shell spawn failed. The server will keep running.", reason);
53 return;
54 }
55 // Another handler registered besides this one owns the decision.
56 if (process.listenerCount("unhandledRejection") > 1) {
57 return;
58 }
59 console.error(reason);
60 process.exit(1);
61 });
62}
63
64// Arm before the SDK loads. Its shell tools spawn from this process.
65installCursorShellSpawnGuard();
66
67// Cursor's Webpack chunks and local helpers must stay beside the SDK entry.
68// createRequire also loads that disk-backed package from a Node SEA executable.
69const requireCursorSdk = NodeModule.createRequire(import.meta.url);
70export const {
71 Agent,
72 AuthenticationError,
73 createAgentPlatform,
74 Cursor,
75 CursorSdkError,
76 InMemoryCredentialStore,
77} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk");
apps/server/src/provider/cursorSdkModel.ts deleted-50
...@@ -1,50 +0,0 @@
1import type { ModelSelection as CursorSdkModelSelection, ModelParameterValue } from "@cursor/sdk";
2import type { ModelSelection } from "@t3tools/contracts";
3
4const CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION: Readonly<Record<string, string>> = {
5 context: "contextWindow",
6 fast: "fastMode",
7};
8
9const PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER: Readonly<Record<string, string>> = {
10 contextWindow: "context",
11 fastMode: "fast",
12};
13
14export function cursorSdkProviderOptionId(parameterId: string): string {
15 return CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION[parameterId] ?? parameterId;
16}
17
18function cursorSdkParameterId(providerOptionId: string): string {
19 return PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER[providerOptionId] ?? providerOptionId;
20}
21
22export function cursorSdkParameterPriority(parameterId: string): number {
23 switch (parameterId) {
24 case "effort":
25 case "reasoning":
26 return 0;
27 case "context":
28 return 1;
29 case "fast":
30 return 2;
31 case "thinking":
32 return 3;
33 default:
34 return 4;
35 }
36}
37
38export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection {
39 return {
40 id: modelSelection.model === "auto" ? "default" : modelSelection.model,
41 ...(modelSelection.options === undefined || modelSelection.options.length === 0
42 ? {}
43 : {
44 params: modelSelection.options.map((option): ModelParameterValue => ({
45 id: cursorSdkParameterId(option.id),
46 value: String(option.value),
47 })),
48 }),
49 };
50}
apps/server/src/provider/cursorUsageLimits.test.ts deleted-264
...@@ -1,264 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { NodeServices } from "@effect/platform-node";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import { HttpClient, HttpClientResponse } from "effect/http";
8import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts";
9
10const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
11 effect.pipe(Effect.provide(NodeServices.layer));
12
13describe("Cursor usage limits", () => {
14 const checkedAt = "2026-09-16T00:00:00.000Z";
15
16 it("uses the advertised percentages and billing-cycle reset", () => {
17 const limits = cursorUsageResponseToLimits(
18 {
19 billingCycleEnd: "1789876386000",
20 planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 },
21 },
22 checkedAt,
23 );
24 expect(limits.windows).toEqual(
25 expect.arrayContaining([
26 {
27 id: "totalPercentUsed",
28 kind: "monthly",
29 label: "Overall",
30 usedPercent: 72.4,
31 resetsAt: "2026-09-20T03:53:06.000Z",
32 },
33 {
34 id: "autoPercentUsed",
35 kind: "monthly",
36 label: "Cursor Models",
37 usedPercent: 69.5,
38 resetsAt: "2026-09-20T03:53:06.000Z",
39 },
40 {
41 id: "apiPercentUsed",
42 kind: "monthly",
43 label: "Other Models",
44 usedPercent: 100,
45 resetsAt: "2026-09-20T03:53:06.000Z",
46 },
47 ]),
48 );
49 });
50
51 it("does not invent unused allowance for absent buckets", () => {
52 expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe(
53 "unsupported",
54 );
55 expect(
56 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows,
57 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]);
58 expect(
59 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows,
60 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]);
61 });
62
63 it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () =>
64 Effect.gen(function* () {
65 yield* withNodeServices(
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const directory = yield* fs.makeTempDirectoryScoped();
70 yield* fs.makeDirectory(path.join(directory, "cursor"));
71 yield* fs.writeFileString(
72 path.join(directory, "cursor", "auth.json"),
73 '{"accessToken":"instance-token"}',
74 );
75 const client = HttpClient.make((request) => {
76 expect(request.url).toBe(
77 "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
78 );
79 expect(request.method).toBe("POST");
80 expect(request.headers.authorization).toBe("Bearer instance-token");
81 expect(request.headers["connect-protocol-version"]).toBe("1");
82 return Effect.succeed(
83 HttpClientResponse.fromWeb(
84 request,
85 Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }),
86 ),
87 );
88 });
89 yield* fs.makeDirectory(path.join(directory, ".cursor"));
90 yield* fs.writeFileString(
91 path.join(directory, ".cursor", "auth.json"),
92 '{"accessToken":"instance-token"}',
93 );
94 for (const platform of ["linux", "darwin"] as const) {
95 const limits = yield* readCursorUsageLimits(
96 { apiEndpoint: "https://cursor.example/" },
97 { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" },
98 ).pipe(
99 Effect.provideService(HostProcessPlatform, platform),
100 Effect.provideService(HttpClient.HttpClient, client),
101 );
102 expect(limits.windows[0]?.usedPercent).toBe(42);
103 }
104 }).pipe(Effect.scoped),
105 );
106 }),
107 );
108
109 it.effect(
110 "never reads stale files for keychain or memory logins, but accepts an explicit auth token",
111 () =>
112 Effect.gen(function* () {
113 for (const platform of ["linux", "darwin"] as const) {
114 for (const token of [undefined, "explicit-token"]) {
115 const limits = yield* withNodeServices(
116 readCursorUsageLimits(
117 { apiEndpoint: "" },
118 {
119 AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default",
120 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),
121 },
122 false,
123 async () => {
124 throw new Error("must not read Keychain before opt-in");
125 },
126 ).pipe(
127 Effect.provideService(HostProcessPlatform, platform),
128 Effect.provideService(
129 FileSystem.FileSystem,
130 FileSystem.makeNoop({
131 readFileString: () => Effect.die("must not read an unrelated credential file"),
132 }),
133 ),
134 Effect.provideService(
135 HttpClient.HttpClient,
136 HttpClient.make((request) => {
137 expect(token).toBe("explicit-token");
138 expect(request.headers.authorization).toBe("Bearer explicit-token");
139 return Effect.succeed(
140 HttpClientResponse.fromWeb(
141 request,
142 Response.json({ planUsage: { totalPercentUsed: 10 } }),
143 ),
144 );
145 }),
146 ),
147 ),
148 );
149 if (token) expect(limits.windows[0]?.usedPercent).toBe(10);
150 else expect(limits.unavailable?.reason).toBe("unsupported");
151 }
152 }
153 }),
154 );
155
156 it.effect("reports failed requests without exposing credentials or response bodies", () =>
157 Effect.gen(function* () {
158 const limits = yield* withNodeServices(
159 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe(
160 Effect.provideService(
161 HttpClient.HttpClient,
162 HttpClient.make((request) =>
163 Effect.succeed(
164 HttpClientResponse.fromWeb(
165 request,
166 new Response("private response", { status: 401 }),
167 ),
168 ),
169 ),
170 ),
171 ),
172 );
173 expect(limits.unavailable).toEqual({
174 reason: "probeFailed",
175 message: "Cursor could not read usage limits.",
176 });
177 }),
178 );
179
180 it.effect("does not use a stored login for an explicit API key", () =>
181 Effect.gen(function* () {
182 const limits = yield* withNodeServices(
183 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe(
184 Effect.provideService(
185 HttpClient.HttpClient,
186 HttpClient.make(() => Effect.die("must not request usage")),
187 ),
188 ),
189 );
190 expect(limits.unavailable?.reason).toBe("unsupported");
191 }),
192 );
193
194 it.effect("reads the default macOS Cursor login from Keychain for limits", () =>
195 Effect.gen(function* () {
196 const limits = yield* withNodeServices(
197 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe(
198 Effect.provideService(HostProcessPlatform, "darwin"),
199 Effect.provideService(
200 FileSystem.FileSystem,
201 FileSystem.makeNoop({
202 readFileString: () => Effect.die("must not read a stale credential file"),
203 }),
204 ),
205 Effect.provideService(
206 HttpClient.HttpClient,
207 HttpClient.make((request) => {
208 expect(request.headers.authorization).toBe("Bearer keychain-token");
209 return Effect.succeed(
210 HttpClientResponse.fromWeb(
211 request,
212 Response.json({ planUsage: { totalPercentUsed: 42 } }),
213 ),
214 );
215 }),
216 ),
217 ),
218 );
219 expect(limits.windows[0]?.usedPercent).toBe(42);
220 }),
221 );
222
223 it.effect("reports a Keychain initialization failure without failing the provider refresh", () =>
224 Effect.gen(function* () {
225 const limits = yield* withNodeServices(
226 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => {
227 throw new Error("Keychain initialization failed");
228 }).pipe(
229 Effect.provideService(HostProcessPlatform, "darwin"),
230 Effect.provideService(
231 HttpClient.HttpClient,
232 HttpClient.make(() => Effect.die("must not request limits without a login")),
233 ),
234 ),
235 );
236 expect(limits.unavailable?.reason).toBe("probeFailed");
237 }),
238 );
239
240 it.effect("does not read Keychain or send its token to a custom endpoint", () =>
241 Effect.gen(function* () {
242 for (const [apiEndpoint, environment] of [
243 ["http://localhost:3000", {}],
244 ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }],
245 ["https://cursor-proxy.example", {}],
246 ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }],
247 ] as const) {
248 const limits = yield* withNodeServices(
249 readCursorUsageLimits({ apiEndpoint }, environment, true, async () => {
250 throw new Error("must not read Keychain for a custom endpoint");
251 }).pipe(
252 Effect.provideService(HostProcessPlatform, "darwin"),
253 Effect.provideService(
254 HttpClient.HttpClient,
255 HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")),
256 ),
257 ),
258 );
259 expect(limits.unavailable?.reason).toBe("unsupported");
260 expect(limits.unavailable?.message).toContain("default Cursor endpoint");
261 }
262 }),
263 );
264});
apps/server/src/provider/cursorUsageLimits.ts deleted-151
...@@ -1,151 +0,0 @@
1import * as NodeOS from "node:os";
2import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
12import {
13 clampPercent,
14 makeUnavailableUsageLimits,
15 makeUsageLimits,
16} from "@t3tools/provider-core/server/usageLimits";
17import { readMacCursorAccessToken } from "./cursorKeychainToken.ts";
18
19const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
20const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";
21const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));
22const CursorUsageResponse = Schema.Struct({
23 billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
24 planUsage: Schema.optional(
25 Schema.Struct({
26 totalPercentUsed: Schema.optional(Schema.Number),
27 autoPercentUsed: Schema.optional(Schema.Number),
28 apiPercentUsed: Schema.optional(Schema.Number),
29 }),
30 ),
31});
32
33/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */
34export function cursorUsageResponseToLimits(
35 response: typeof CursorUsageResponse.Type,
36 checkedAt: string,
37) {
38 const reset = DateTime.make(Number(response.billingCycleEnd));
39 const resetsAt =
40 Number(response.billingCycleEnd) > 0 && Option.isSome(reset)
41 ? DateTime.formatIso(reset.value)
42 : undefined;
43 const windows: ServerProviderUsageWindow[] = [];
44 if (response.planUsage) {
45 for (const { id, label } of CURSOR_USAGE_WINDOWS) {
46 const usedPercent = response.planUsage[id];
47 if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;
48 windows.push({
49 id,
50 kind: "monthly",
51 label,
52 usedPercent: clampPercent(usedPercent),
53 ...(resetsAt ? { resetsAt } : {}),
54 });
55 }
56 }
57 return windows.length > 0
58 ? makeUsageLimits({ checkedAt, windows })
59 : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
60}
61
62export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* (
63 settings: Pick<CursorSettings, "apiEndpoint">,
64 environment: NodeJS.ProcessEnv = process.env,
65 allowKeychain = false,
66 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
67) {
68 const checkedAt = DateTime.formatIso(yield* DateTime.now);
69 return yield* Effect.gen(function* () {
70 const fs = yield* FileSystem.FileSystem;
71 const path = yield* Path.Path;
72 const platform = yield* HostProcessPlatform;
73 const endpoint = (
74 settings.apiEndpoint?.trim() ||
75 environment.CURSOR_API_ENDPOINT?.trim() ||
76 DEFAULT_CURSOR_API_ENDPOINT
77 ).replace(/\/$/, "");
78 let token = environment.CURSOR_AUTH_TOKEN?.trim();
79 // An explicit API key can name a different account from the stored login.
80 if (!token && environment.CURSOR_API_KEY?.trim()) {
81 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
82 }
83 const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE;
84 if (!token && credentialStore === "memory") {
85 return makeUnavailableUsageLimits({
86 checkedAt,
87 reason: "unsupported",
88 message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.",
89 });
90 }
91 if (!token && platform === "darwin" && credentialStore !== "file") {
92 if (!allowKeychain) {
93 return makeUnavailableUsageLimits({
94 checkedAt,
95 reason: "unsupported",
96 message: "Enable Cursor account usage in T3 Code to read its Keychain login.",
97 });
98 }
99 if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) {
100 return makeUnavailableUsageLimits({
101 checkedAt,
102 reason: "unsupported",
103 message: "Cursor account usage requires the default Cursor endpoint when using Keychain.",
104 });
105 }
106 token = (yield* Effect.tryPromise(keychainToken))?.trim();
107 } else if (!token) {
108 const home =
109 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();
110 const directory =
111 platform === "win32"
112 ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor")
113 : platform === "darwin"
114 ? path.join(home, ".cursor")
115 : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor");
116 const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe(
117 Effect.catchTags({
118 PlatformError: (error) =>
119 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
120 }),
121 Effect.flatMap(decodeCredentials),
122 );
123 token = credentials.accessToken?.trim();
124 }
125 if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
126 const client = yield* HttpClient.HttpClient;
127 const response = yield* client.execute(
128 HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe(
129 HttpClientRequest.bearerToken(token),
130 HttpClientRequest.setHeaders({
131 "connect-protocol-version": "1",
132 "x-cursor-client-type": "cli",
133 }),
134 HttpClientRequest.bodyJsonUnsafe({}),
135 ),
136 );
137 const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)(
138 yield* HttpClientResponse.filterStatusOk(response),
139 );
140 return cursorUsageResponseToLimits(body, checkedAt);
141 }).pipe(
142 Effect.timeout("10 seconds"),
143 Effect.orElseSucceed(() =>
144 makeUnavailableUsageLimits({
145 checkedAt,
146 reason: "probeFailed",
147 message: "Cursor could not read usage limits.",
148 }),
149 ),
150 );
151});
apps/server/src/textGeneration/CursorTextGeneration.test.ts deleted-415
...@@ -1,415 +0,0 @@
1import type { RunResult } from "@cursor/sdk";
2import { CursorSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Fiber from "effect/Fiber";
7import * as TestClock from "effect/testing/TestClock";
8import * as Schema from "effect/Schema";
9import { createModelSelection } from "@t3tools/shared/model";
10import { beforeEach, vi } from "vite-plus/test";
11
12import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
13
14const cursorSdkMock = vi.hoisted(() => ({
15 create: vi.fn<(options: unknown) => Promise<unknown>>(),
16 send: vi.fn<(prompt: string) => Promise<unknown>>(),
17 close: vi.fn(),
18 cancel: vi.fn(async () => {}),
19 prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({
20 id: "run-cursor-text-generation-test",
21 status: "finished",
22 result:
23 '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}',
24 })),
25}));
26
27vi.mock("../provider/cursorSdk.ts", () => ({ Agent: { create: cursorSdkMock.create } }));
28
29let hasCustomPolicy = false;
30const layerFs = FileSystem.layerNoop({
31 exists: () => Effect.succeed(hasCustomPolicy),
32 makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"),
33});
34
35const decodeCursorSettings = Schema.decodeSync(CursorSettings);
36const cursorSettings = decodeCursorSettings({ enabled: true });
37
38beforeEach(() => {
39 hasCustomPolicy = false;
40 cursorSdkMock.create.mockReset();
41 cursorSdkMock.send.mockReset();
42 cursorSdkMock.create.mockImplementation(async (options) => {
43 cursorSdkMock.send.mockImplementation(async (prompt) => ({
44 status: "running",
45 cancel: cursorSdkMock.cancel,
46 wait: () => cursorSdkMock.prompt(prompt, options),
47 }));
48 return {
49 close: cursorSdkMock.close,
50 [Symbol.asyncDispose]: async () => {
51 cursorSdkMock.close();
52 },
53 send: cursorSdkMock.send,
54 };
55 });
56 cursorSdkMock.close.mockClear();
57 cursorSdkMock.cancel.mockClear();
58 cursorSdkMock.prompt.mockReset();
59 cursorSdkMock.prompt.mockResolvedValue({
60 id: "run-cursor-text-generation-test",
61 status: "finished",
62 result:
63 '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}',
64 });
65});
66
67describe("CursorTextGeneration", () => {
68 it.effect("resolves the browser credential for every request after an account change", () =>
69 Effect.gen(function* () {
70 let apiKey = "first-browser-key";
71 const generation = yield* makeCursorTextGeneration(
72 cursorSettings,
73 {},
74 Effect.sync(() => apiKey),
75 );
76 const input = {
77 cwd: process.cwd(),
78 branch: "feature/cursor",
79 stagedSummary: "M file.ts",
80 stagedPatch: "diff",
81 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "auto"),
82 };
83 yield* generation.generateCommitMessage(input);
84 expect(cursorSdkMock.create).toHaveBeenLastCalledWith(
85 expect.objectContaining({ apiKey: "first-browser-key" }),
86 );
87 apiKey = "second-browser-key";
88 yield* generation.generateCommitMessage(input);
89 expect(cursorSdkMock.create).toHaveBeenLastCalledWith(
90 expect.objectContaining({ apiKey: "second-browser-key" }),
91 );
92 }).pipe(Effect.provide(layerFs)),
93 );
94
95 it.effect("uses the Cursor SDK prompt API with model parameters and API key", () =>
96 Effect.gen(function* () {
97 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
98 CURSOR_API_KEY: "test-cursor-key",
99 });
100
101 const generated = yield* textGeneration.generateCommitMessage({
102 cwd: process.cwd(),
103 branch: "feature/cursor-text-generation",
104 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
105 stagedPatch:
106 "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts",
107 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [
108 { id: "thinking", value: "high" },
109 { id: "contextWindow", value: "1m" },
110 { id: "fastMode", value: true },
111 ]),
112 });
113
114 expect(generated.subject).toBe("Add generated commit message");
115 expect(generated.body).toBe("- verify cursor sdk text generation");
116
117 expect(cursorSdkMock.prompt).toHaveBeenCalledTimes(1);
118 const [prompt, options] = (
119 cursorSdkMock.prompt.mock.calls as unknown as Array<[string, unknown]>
120 )[0]!;
121 expect(prompt).toContain("Staged patch:");
122 expect(options).toEqual({
123 apiKey: "test-cursor-key",
124 mode: "plan",
125 model: {
126 id: "gpt-5.4",
127 params: [
128 { id: "thinking", value: "high" },
129 { id: "context", value: "1m" },
130 { id: "fast", value: "true" },
131 ],
132 },
133 local: {
134 cwd: "/isolated-text-generation",
135 autoReview: false,
136 sandboxOptions: { enabled: true },
137 settingSources: [],
138 enableAgentRetries: true,
139 },
140 });
141 }).pipe(Effect.provide(layerFs)),
142 );
143
144 it.effect("continues in the temp directory when the SDK cannot sandbox", () =>
145 Effect.gen(function* () {
146 cursorSdkMock.create.mockImplementationOnce(async () => {
147 throw new Error(
148 "Local SDK sandboxing was requested, but sandboxing is not supported in this environment. Disable local.sandboxOptions.enabled or remove ~/.cursor/sandbox.json to run without sandboxing.",
149 );
150 });
151 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
152 CURSOR_API_KEY: "test-cursor-key",
153 });
154
155 const generated = yield* textGeneration.generateCommitMessage({
156 cwd: process.cwd(),
157 branch: "feature/cursor-text-generation",
158 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
159 stagedPatch: "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts",
160 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"),
161 });
162
163 expect(generated.subject).toBe("Add generated commit message");
164 expect(cursorSdkMock.create).toHaveBeenCalledTimes(2);
165 expect(cursorSdkMock.create.mock.calls[0]?.[0]).toMatchObject({
166 local: { sandboxOptions: { enabled: true } },
167 });
168 expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({
169 local: {
170 cwd: "/isolated-text-generation",
171 autoReview: false,
172 sandboxOptions: { enabled: false },
173 settingSources: [],
174 enableAgentRetries: true,
175 },
176 });
177 }).pipe(Effect.provide(layerFs)),
178 );
179
180 it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () =>
181 Effect.gen(function* () {
182 cursorSdkMock.create.mockImplementationOnce(async () => {
183 throw new Error("Cursor SDK network down");
184 });
185 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
186 CURSOR_API_KEY: "test-cursor-key",
187 });
188
189 const error = yield* Effect.flip(
190 textGeneration.generateCommitMessage({
191 cwd: process.cwd(),
192 branch: "feature/cursor-text-generation",
193 stagedSummary: "M README.md",
194 stagedPatch: "diff --git a/README.md b/README.md",
195 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"),
196 }),
197 );
198
199 expect(error.detail).toBe("Cursor SDK text generation failed.");
200 expect(cursorSdkMock.create).toHaveBeenCalledTimes(1);
201 }).pipe(Effect.provide(layerFs)),
202 );
203
204 it.effect("accepts json objects with extra assistant text around them", () =>
205 Effect.gen(function* () {
206 cursorSdkMock.prompt.mockResolvedValueOnce({
207 id: "run-cursor-text-generation-test",
208 status: "finished",
209 result:
210 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
211 });
212 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
213 CURSOR_API_KEY: "test-cursor-key",
214 });
215
216 const generated = yield* textGeneration.generateCommitMessage({
217 cwd: process.cwd(),
218 branch: "feature/cursor-noisy-json",
219 stagedSummary: "M README.md",
220 stagedPatch: "diff --git a/README.md b/README.md",
221 modelSelection: {
222 instanceId: ProviderInstanceId.make("cursor"),
223 model: "composer-2",
224 },
225 });
226
227 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
228 expect(generated.body).toBe("");
229 }).pipe(Effect.provide(layerFs)),
230 );
231
232 it.effect("generates thread titles through Cursor SDK text generation", () =>
233 Effect.gen(function* () {
234 cursorSdkMock.prompt.mockResolvedValueOnce({
235 id: "run-cursor-title-generation-test",
236 status: "finished",
237 result: '{"title":"\\"Trim reconnect spinner status after resume.\\""}',
238 });
239 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
240 CURSOR_API_KEY: "test-cursor-key",
241 });
242
243 const generated = yield* textGeneration.generateThreadTitle({
244 cwd: process.cwd(),
245 message: "Fix the reconnect spinner after a resumed session.",
246 modelSelection: {
247 instanceId: ProviderInstanceId.make("cursor"),
248 model: "composer-2",
249 },
250 });
251
252 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
253 }).pipe(Effect.provide(layerFs)),
254 );
255
256 it.effect.each(["error", "cancelled"] as const)(
257 "rejects a %s Cursor SDK run that includes valid title JSON",
258 (status) =>
259 Effect.gen(function* () {
260 const promptResult = {
261 id: "run-cursor-partial-title-test",
262 status,
263 result: '{"title":"Partial title from a failed run."}',
264 } satisfies RunResult;
265 cursorSdkMock.prompt.mockResolvedValueOnce(promptResult);
266 const generation = yield* makeCursorTextGeneration(cursorSettings, {
267 CURSOR_API_KEY: "test-cursor-key",
268 });
269 const failure = yield* Effect.flip(
270 generation.generateThreadTitle({
271 cwd: process.cwd(),
272 message: "Fix the reconnect spinner after a resumed session.",
273 modelSelection: {
274 instanceId: ProviderInstanceId.make("cursor"),
275 model: "composer-2",
276 },
277 }),
278 );
279 expect(failure).toBeInstanceOf(TextGenerationError);
280 expect(failure.operation).toBe("generateThreadTitle");
281 expect(failure.detail).toBe(
282 status === "cancelled"
283 ? "Cursor SDK request was cancelled."
284 : "Cursor SDK request finished with an error.",
285 );
286 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
287 }).pipe(Effect.provide(layerFs)),
288 );
289
290 it.effect("fails closed when ambient sandbox policy can expand write access", () =>
291 Effect.gen(function* () {
292 hasCustomPolicy = true;
293 const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" });
294 const failure = yield* Effect.flip(
295 generation.generateThreadTitle({
296 cwd: "/real-workspace",
297 message: "Title",
298 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
299 }),
300 );
301 expect(failure.detail).toContain("custom ~/.cursor/sandbox.json");
302 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();
303 }).pipe(Effect.provide(layerFs)),
304 );
305
306 it.effect("cancels the native run when text generation times out", () =>
307 Effect.gen(function* () {
308 let started!: () => void;
309 const called = new Promise<void>((resolve) => {
310 started = resolve;
311 });
312 cursorSdkMock.prompt.mockImplementationOnce(() => {
313 started();
314 return new Promise(() => {});
315 });
316 const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" });
317 const result = yield* generation
318 .generateThreadTitle({
319 cwd: "/real-workspace",
320 message: "Title",
321 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
322 })
323 .pipe(Effect.flip, Effect.forkScoped);
324 yield* Effect.promise(() => called);
325 yield* TestClock.adjust("180 seconds");
326 expect((yield* Fiber.join(result)).detail).toContain("timed out");
327 expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();
328 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
329 }).pipe(Effect.provide(layerFs), Effect.scoped),
330 );
331
332 it.effect.each(["create", "send"] as const)(
333 "times out pending %s and releases its late SDK resource",
334 (phase) =>
335 Effect.gen(function* () {
336 let started!: () => void;
337 const called = new Promise<void>((resolve) => {
338 started = resolve;
339 });
340 let resolveLate!: (resource: unknown) => void;
341 const pending = new Promise<unknown>((resolve) => {
342 resolveLate = resolve;
343 });
344 const wait = vi.fn();
345 const agent = {
346 close: cursorSdkMock.close,
347 [Symbol.asyncDispose]: async () => {
348 cursorSdkMock.close();
349 },
350 send: async () => {
351 started();
352 return pending;
353 },
354 };
355 cursorSdkMock.create.mockImplementation(async () => {
356 if (phase === "create") {
357 started();
358 return pending;
359 }
360 return agent;
361 });
362 const generation = yield* makeCursorTextGeneration(cursorSettings, {
363 CURSOR_API_KEY: "key",
364 });
365 const result = yield* generation
366 .generateThreadTitle({
367 cwd: "/real-workspace",
368 message: "Title",
369 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
370 })
371 .pipe(Effect.flip, Effect.forkScoped);
372 yield* Effect.promise(() => called);
373 yield* TestClock.adjust("180 seconds");
374 expect((yield* Fiber.join(result)).detail).toContain("timed out");
375 let cleanup!: () => void;
376 const cleaned = new Promise<void>((resolve) => {
377 cleanup = resolve;
378 });
379 if (phase === "create") cursorSdkMock.close.mockImplementationOnce(cleanup);
380 else
381 cursorSdkMock.cancel.mockImplementationOnce(async () => {
382 cleanup();
383 });
384 resolveLate(
385 phase === "create" ? agent : { status: "running", cancel: cursorSdkMock.cancel, wait },
386 );
387 yield* Effect.promise(() => cleaned);
388 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
389 expect(wait).not.toHaveBeenCalled();
390 if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();
391 }).pipe(Effect.provide(layerFs), Effect.scoped),
392 );
393
394 it.effect("requires CURSOR_API_KEY before calling the SDK", () =>
395 Effect.gen(function* () {
396 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {});
397
398 const error = yield* Effect.flip(
399 textGeneration.generateCommitMessage({
400 cwd: process.cwd(),
401 branch: "feature/cursor-api-key",
402 stagedSummary: "M README.md",
403 stagedPatch: "diff --git a/README.md b/README.md",
404 modelSelection: {
405 instanceId: ProviderInstanceId.make("cursor"),
406 model: "composer-2",
407 },
408 }),
409 );
410
411 expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings.");
412 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();
413 }).pipe(Effect.provide(layerFs)),
414 );
415});
apps/server/src/textGeneration/CursorTextGeneration.ts deleted-204
...@@ -1,204 +0,0 @@
1import * as NodeOS from "node:os";
2import * as FileSystem from "effect/FileSystem";
3
4import type { AgentOptions, RunResult } from "@cursor/sdk";
5import { Agent } from "../provider/cursorSdk.ts";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9
10import {
11 type CursorSettings,
12 type ProviderSetupError,
13 TextGenerationError,
14} from "@t3tools/contracts";
15
16import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
17import { cursorSdkModelSelection } from "../provider/cursorSdkModel.ts";
18import type { CursorAuth } from "../provider/CursorAuth.ts";
19
20const CURSOR_TIMEOUT_MS = 180_000;
21
22const isTextGenerationError = Schema.is(TextGenerationError);
23
24function cursorSdkResultDetail(result: RunResult): string {
25 switch (result.status) {
26 case "cancelled":
27 return "Cursor SDK request was cancelled.";
28 case "error":
29 return "Cursor SDK request finished with an error.";
30 case "finished":
31 return "Cursor SDK returned empty output.";
32 }
33}
34
35/**
36 * The SDK throws this when `sandboxOptions.enabled` is set and local sandboxing
37 * is unavailable. That happens on hosts that cannot launch `cursorsandbox`, and
38 * also after an unsandboxed run caches "unsupported" for the process.
39 */
40function cursorSandboxUnsupported(cause: unknown): boolean {
41 const seen = new Set<object>();
42 let current = cause;
43 while (typeof current === "object" && current !== null && !seen.has(current)) {
44 seen.add(current);
45 if (current instanceof Error && current.message.includes("sandboxing is not supported")) {
46 return true;
47 }
48 current = Reflect.get(current, "cause");
49 }
50 return false;
51}
52
53/**
54 * Build a Cursor text-generation closure bound to a specific `CursorSettings`
55 * payload. See `makeCodexAdapter` for the overall per-instance rationale.
56 */
57export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (
58 cursorSettings: CursorSettings,
59 environment?: NodeJS.ProcessEnv,
60 resolveApiKey?: Effect.Effect<string, ProviderSetupError>,
61 withAccess?: CursorAuth["withAccess"],
62) {
63 const fs = yield* FileSystem.FileSystem;
64 const resolvedEnvironment = environment ?? process.env;
65
66 const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>
67 Effect.gen(function* () {
68 if (!cursorSettings.enabled) {
69 return yield* new TextGenerationError({
70 operation,
71 detail: "Cursor is disabled in T3 Code settings.",
72 });
73 }
74
75 const apiKey = resolveApiKey
76 ? yield* resolveApiKey
77 : resolvedEnvironment.CURSOR_API_KEY?.trim();
78 if (!apiKey) {
79 return yield* new TextGenerationError({
80 operation,
81 detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
82 });
83 }
84
85 return apiKey;
86 });
87
88 // Ignores `cwd`: the agent runs in an empty temp directory, away from the project.
89 const runCursorJson: TextGenerationOperations.Runner = (input) => {
90 const { operation, prompt, modelSelection } = input;
91 return Effect.gen(function* () {
92 const apiKey = yield* resolveCursorApiKey(operation);
93 // The SDK loads sandbox.json independently of settingSources and lets it
94 // expand the writable paths. Its public API cannot override that policy.
95 if (yield* fs.exists(`${NodeOS.homedir()}/.cursor/sandbox.json`)) {
96 return yield* new TextGenerationError({
97 operation,
98 detail:
99 "Cursor text generation cannot enforce workspace isolation with a custom ~/.cursor/sandbox.json. Use another text-generation provider.",
100 });
101 }
102 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" });
103 const agentOptions = {
104 apiKey,
105 mode: "plan",
106 model: cursorSdkModelSelection(modelSelection),
107 local: {
108 cwd,
109 autoReview: false,
110 sandboxOptions: { enabled: true },
111 settingSources: [],
112 enableAgentRetries: true,
113 },
114 } satisfies AgentOptions;
115 const createCursorAgent = (sandboxEnabled: boolean) =>
116 Effect.tryPromise((signal) =>
117 Agent.create(
118 sandboxEnabled
119 ? agentOptions
120 : {
121 ...agentOptions,
122 local: {
123 ...agentOptions.local,
124 sandboxOptions: { enabled: false },
125 },
126 },
127 ).then((agent) => {
128 if (signal.aborted) agent.close();
129 return agent;
130 }),
131 );
132
133 const request = Effect.gen(function* () {
134 // Prefer the sandbox. When the SDK refuses it, the empty temp directory
135 // and empty setting sources still keep this run off the user's project.
136 const agent = yield* Effect.acquireRelease(
137 createCursorAgent(true).pipe(
138 Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)),
139 ),
140 (agent) =>
141 Effect.tryPromise(() => agent[Symbol.asyncDispose]()).pipe(
142 Effect.timeout("5 seconds"),
143 Effect.ignore({ log: true }),
144 ),
145 { interruptible: true },
146 );
147 const run = yield* Effect.tryPromise((signal) =>
148 agent.send(prompt).then((run) => {
149 if (signal.aborted) void run.cancel().catch(() => undefined);
150 return run;
151 }),
152 );
153 yield* Effect.addFinalizer(() =>
154 run.status === "running"
155 ? Effect.tryPromise(() => run.cancel()).pipe(
156 Effect.timeout("5 seconds"),
157 Effect.ignore({ log: true }),
158 )
159 : Effect.void,
160 );
161 return yield* Effect.tryPromise(() => run.wait());
162 }).pipe(Effect.scoped);
163 const promptResult = yield* request.pipe(
164 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
165 Effect.flatMap(
166 Option.match({
167 onNone: () =>
168 Effect.fail(
169 new TextGenerationError({
170 operation,
171 detail: "Cursor SDK request timed out.",
172 }),
173 ),
174 onSome: (value) => Effect.succeed(value),
175 }),
176 ),
177 );
178
179 const rawResult = promptResult.result?.trim() ?? "";
180 if (promptResult.status !== "finished" || !rawResult) {
181 return yield* new TextGenerationError({
182 operation,
183 detail: cursorSdkResultDetail(promptResult),
184 });
185 }
186
187 return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult);
188 }).pipe(
189 (effect) => (withAccess ? withAccess(effect) : effect),
190 Effect.scoped,
191 Effect.mapError((cause) =>
192 isTextGenerationError(cause)
193 ? cause
194 : new TextGenerationError({
195 operation,
196 detail: "Cursor SDK text generation failed.",
197 cause,
198 }),
199 ),
200 );
201 };
202
203 return TextGenerationOperations.fromRunner("CursorTextGeneration", runCursorJson);
204});
apps/server/src/usage/cursorUsageReader.test.ts+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
22
3import { CursorKeychainTimeoutError } from "../provider/cursorKeychainToken.ts";3import { CursorKeychainTimeoutError } from "@t3tools/provider-cursor/server";
4import { readCursorAccountUsage } from "./cursorUsageReader.ts";4import { readCursorAccountUsage } from "./cursorUsageReader.ts";
55
6describe("readCursorAccountUsage", () => {6describe("readCursorAccountUsage", () => {
apps/server/src/usage/cursorUsageReader.ts+1-1
...@@ -12,7 +12,7 @@ import type { UsageRecord } from "./usageTranscripts.ts";...@@ -12,7 +12,7 @@ import type { UsageRecord } from "./usageTranscripts.ts";
12import {12import {
13 CursorKeychainTimeoutError,13 CursorKeychainTimeoutError,
14 readMacCursorAccessToken,14 readMacCursorAccessToken,
15} from "../provider/cursorKeychainToken.ts";15} from "@t3tools/provider-cursor/server";
1616
17function object(value: unknown): Record<string, unknown> {17function object(value: unknown): Record<string, unknown> {
18 return typeof value === "object" && value !== null && !Array.isArray(value)18 return typeof value === "object" && value !== null && !Array.isArray(value)
apps/web/package.json+1
...@@ -29,6 +29,7 @@...@@ -29,6 +29,7 @@
29 "@t3tools/client-runtime": "workspace:*",29 "@t3tools/client-runtime": "workspace:*",
30 "@t3tools/contracts": "workspace:*",30 "@t3tools/contracts": "workspace:*",
31 "@t3tools/provider-core": "workspace:*",31 "@t3tools/provider-core": "workspace:*",
32 "@t3tools/provider-cursor": "workspace:*",
32 "@t3tools/provider-muse": "workspace:*",33 "@t3tools/provider-muse": "workspace:*",
33 "@t3tools/provider-opencode": "workspace:*",34 "@t3tools/provider-opencode": "workspace:*",
34 "@t3tools/provider-pi": "workspace:*",35 "@t3tools/provider-pi": "workspace:*",
apps/web/src/components/chat/ProviderInstanceIcon.tsx+1-3
...@@ -3,7 +3,7 @@ import { type CSSProperties, memo } from "react";...@@ -3,7 +3,7 @@ import { type CSSProperties, memo } from "react";
3import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";3import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
44
5import { ProviderDriverKind } from "@t3tools/contracts";5import { ProviderDriverKind } from "@t3tools/contracts";
6import { AntigravityIcon, ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI } from "../Icons";6import { AntigravityIcon, ClaudeAI, GrokIcon, Icon, OpenAI } from "../Icons";
77
8import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
9import { providerClients } from "../settings/providerDriverMeta";9import { providerClients } from "../settings/providerDriverMeta";
...@@ -17,7 +17,6 @@ import {...@@ -17,7 +17,6 @@ import {
17const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {17const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
18 [ProviderDriverKind.make("codex")]: OpenAI,18 [ProviderDriverKind.make("codex")]: OpenAI,
19 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,19 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,
20 [ProviderDriverKind.make("cursor")]: CursorIcon,
21 [ProviderDriverKind.make("grok")]: GrokIcon,20 [ProviderDriverKind.make("grok")]: GrokIcon,
22 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,21 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,
23};22};
...@@ -25,7 +24,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {...@@ -25,7 +24,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
25const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {24const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {
26 [ProviderDriverKind.make("codex")]: "text-black dark:text-white",25 [ProviderDriverKind.make("codex")]: "text-black dark:text-white",
27 [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]",26 [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]",
28 [ProviderDriverKind.make("cursor")]: "text-[#26251E] dark:text-[#EDECEC]",
29 [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]",27 [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]",
30 [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]",28 [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]",
31};29};
apps/web/src/components/settings/providerDriverMeta.ts+2-15
...@@ -3,11 +3,11 @@ import {...@@ -3,11 +3,11 @@ import {
3 AntigravitySettings,3 AntigravitySettings,
4 ClaudeSettings,4 ClaudeSettings,
5 CodexSettings,5 CodexSettings,
6 CursorSettings,
7 GrokSettings,6 GrokSettings,
8 ProviderDriverKind,7 ProviderDriverKind,
9} from "@t3tools/contracts";8} from "@t3tools/contracts";
10import { makeProviderClientRegistry } from "@t3tools/provider-core/client";9import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
10import { cursorClient } from "@t3tools/provider-cursor/client";
11import { museClient } from "@t3tools/provider-muse/client";11import { museClient } from "@t3tools/provider-muse/client";
12import { openCodeClient } from "@t3tools/provider-opencode/client";12import { openCodeClient } from "@t3tools/provider-opencode/client";
13import { piClient } from "@t3tools/provider-pi/client";13import { piClient } from "@t3tools/provider-pi/client";
...@@ -24,20 +24,7 @@ export const providerClients = makeProviderClientRegistry([...@@ -24,20 +24,7 @@ export const providerClients = makeProviderClientRegistry([
24 label: "Claude",24 label: "Claude",
25 settingsSchema: ClaudeSettings,25 settingsSchema: ClaudeSettings,
26 },26 },
27 {27 cursorClient,
28 driverKind: ProviderDriverKind.make("cursor"),
29 label: "Cursor",
30 settingsSchema: CursorSettings,
31 environmentFields: [
32 {
33 name: "CURSOR_API_KEY",
34 label: "Cursor API key",
35 description: "Optional. Overrides browser sign-in for this provider.",
36 placeholder: "Paste API key",
37 sensitive: true,
38 },
39 ],
40 },
41 {28 {
42 driverKind: ProviderDriverKind.make("grok"),29 driverKind: ProviderDriverKind.make("grok"),
43 label: "Grok",30 label: "Grok",
docs/internals/adding-a-provider.md+1-1
...@@ -115,7 +115,7 @@ A provider lives in its own `packages/provider-<name>` package, with...@@ -115,7 +115,7 @@ A provider lives in its own `packages/provider-<name>` package, with
115 Settings live in `providerInstances`; new providers are off by default.115 Settings live in `providerInstances`; new providers are off by default.
116- **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts,116- **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts,
117 and shared. It reaches the server through `ProviderHost` (paths, settings, background demand,117 and shared. It reaches the server through `ProviderHost` (paths, settings, background demand,
118 attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`.118 attachments, stored credentials), never through `apps/server`. Tests use `@t3tools/provider-testing`.
119- **Server registration:** the driver's entry in119- **Server registration:** the driver's entry in
120 [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts) and the adapter driver's in120 [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts) and the adapter driver's in
121 [`builtInProviderAdapterDrivers.ts`](../../apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts).121 [`builtInProviderAdapterDrivers.ts`](../../apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts).
knip.jsonc+3-1
...@@ -36,7 +36,9 @@...@@ -36,7 +36,9 @@
36 ],36 ],
37 // Keep the transitive Effect runtime pinned for standalone npm installs.37 // Keep the transitive Effect runtime pinned for standalone npm installs.
38 // The Vite+ web build prerequisite.38 // The Vite+ web build prerequisite.
39 "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web"],39 // Native and disk-loaded provider packages stay external to the bundle; the
40 // CLI archive and desktop sidecar stage them from this manifest.
41 "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web", "@napi-rs/keyring"],
40 },42 },
41 "apps/desktop": {43 "apps/desktop": {
42 // Electron loads these bundles by filename rather than importing them.44 // Electron loads these bundles by filename rather than importing them.
package.json+1-1
...@@ -28,7 +28,7 @@...@@ -28,7 +28,7 @@
28 "tc": "vp run -r --concurrency-limit 2 typecheck",28 "tc": "vp run -r --concurrency-limit 2 typecheck",
29 "lint": "vp lint --report-unused-disable-directives",29 "lint": "vp lint --report-unused-disable-directives",
30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "test": "vp run -r test",34 "test": "vp run -r test",
packages/contracts/src/settings.ts-26
...@@ -707,32 +707,6 @@ export const ClaudeSettings = makeProviderSettingsSchema(...@@ -707,32 +707,6 @@ export const ClaudeSettings = makeProviderSettingsSchema(
707);707);
708export type ClaudeSettings = typeof ClaudeSettings.Type;708export type ClaudeSettings = typeof ClaudeSettings.Type;
709709
710export const CursorSettings = makeProviderSettingsSchema(
711 {
712 // Off by default like Grok and OpenCode. Users opt in from Settings.
713 enabled: Schema.Boolean.pipe(
714 Schema.withDecodingDefault(Effect.succeed(false)),
715 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
716 ),
717 // Keep V1's CLI configuration when V2 rewrites the shared settings file.
718 // V2's Cursor SDK does not use these fields.
719 binaryPath: Schema.optionalKey(TrimmedString).pipe(
720 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
721 ),
722 apiEndpoint: Schema.optionalKey(TrimmedString).pipe(
723 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
724 ),
725 customModels: Schema.Array(CustomModelSetting).pipe(
726 Schema.withDecodingDefault(Effect.succeed([])),
727 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
728 ),
729 },
730 {
731 order: [],
732 },
733);
734export type CursorSettings = typeof CursorSettings.Type;
735
736export const GrokSettings = makeProviderSettingsSchema(710export const GrokSettings = makeProviderSettingsSchema(
737 {711 {
738 // Off by default (like Cursor and OpenCode): the binding is not yet712 // Off by default (like Cursor and OpenCode): the binding is not yet
packages/provider-core/src/server/ProviderHost.ts+17
...@@ -17,9 +17,12 @@ import type {...@@ -17,9 +17,12 @@ import type {
17} from "@t3tools/contracts";17} from "@t3tools/contracts";
18import * as Context from "effect/Context";18import * as Context from "effect/Context";
19import type * as Effect from "effect/Effect";19import type * as Effect from "effect/Effect";
20import type * as Option from "effect/Option";
20import type * as Scope from "effect/Scope";21import type * as Scope from "effect/Scope";
21import type * as Stream from "effect/Stream";22import type * as Stream from "effect/Stream";
2223
24import type { ProviderCredentialError } from "./errors.ts";
25
23export interface ProviderHostPaths {26export interface ProviderHostPaths {
24 /** Directory the server was started in; the fallback cwd for sessions without a project. */27 /** Directory the server was started in; the fallback cwd for sessions without a project. */
25 readonly cwd: string;28 readonly cwd: string;
...@@ -31,6 +34,15 @@ export interface ProviderHostPaths {...@@ -31,6 +34,15 @@ export interface ProviderHostPaths {
31 readonly providerStatusCacheDir: string;34 readonly providerStatusCacheDir: string;
32}35}
3336
37/** One opaque credential blob in the server's secret store. Only the provider decodes it. */
38export interface ProviderCredentials {
39 /** Instances whose bindings are equal share these credentials. */
40 readonly binding: { readonly owner: "t3"; readonly key: string };
41 readonly get: Effect.Effect<Option.Option<Uint8Array>, ProviderCredentialError>;
42 readonly set: (credentials: Uint8Array) => Effect.Effect<void, ProviderCredentialError>;
43 readonly remove: Effect.Effect<void, ProviderCredentialError>;
44}
45
34export interface ProviderHostShape {46export interface ProviderHostShape {
35 readonly paths: ProviderHostPaths;47 readonly paths: ProviderHostPaths;
36 readonly settings: {48 readonly settings: {
...@@ -47,6 +59,11 @@ export interface ProviderHostShape {...@@ -47,6 +59,11 @@ export interface ProviderHostShape {
47 * resolve inside the attachments directory.59 * resolve inside the attachments directory.
48 */60 */
49 readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;61 readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
62 /** The credentials stored for `bindingId`, usually an instance id, under a provider-chosen namespace. */
63 readonly credentials: (
64 namespace: string,
65 bindingId: string,
66 ) => Effect.Effect<ProviderCredentials>;
50}67}
5168
52export class ProviderHost extends Context.Service<ProviderHost, ProviderHostShape>()(69export class ProviderHost extends Context.Service<ProviderHost, ProviderHostShape>()(
packages/provider-core/src/server/errors.ts+13
...@@ -19,3 +19,16 @@ export class ProviderDriverError extends Schema.TaggedError<ProviderDriverError>...@@ -19,3 +19,16 @@ export class ProviderDriverError extends Schema.TaggedError<ProviderDriverError>
19 return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`;19 return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`;
20 }20 }
21}21}
22
23/** Reading or writing a provider's stored credentials failed. */
24export class ProviderCredentialError extends Schema.TaggedError<ProviderCredentialError>()(
25 "ProviderCredentialError",
26 {
27 operation: Schema.Literals(["get", "set", "remove"]),
28 cause: Schema.Defect(),
29 },
30) {
31 override get message(): string {
32 return `Could not ${this.operation} stored provider credentials.`;
33 }
34}
packages/provider-core/src/server/managedProvider.test.ts+1
...@@ -117,6 +117,7 @@ function layerProviderHost(input: {...@@ -117,6 +117,7 @@ function layerProviderHost(input: {
117 },117 },
118 shouldRunBackgroundWork: () => Effect.succeed(input.runBackgroundWork),118 shouldRunBackgroundWork: () => Effect.succeed(input.runBackgroundWork),
119 resolveAttachmentPath: () => null,119 resolveAttachmentPath: () => null,
120 credentials: () => Effect.die("unused"),
120 }),121 }),
121 );122 );
122}123}
packages/provider-cursor/package.json created+47
...@@ -0,0 +1,47 @@
1{
2 "name": "@t3tools/provider-cursor",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./client": {
7 "types": "./src/client.ts",
8 "import": "./src/client.ts"
9 },
10 "./settings": {
11 "types": "./src/settings.ts",
12 "import": "./src/settings.ts"
13 },
14 "./server": {
15 "types": "./src/server.ts",
16 "import": "./src/server.ts"
17 },
18 "./testing": {
19 "types": "./src/testing.ts",
20 "import": "./src/testing.ts"
21 },
22 "./server/CursorAgentSdk": {
23 "types": "./src/server/CursorAgentSdk.ts",
24 "import": "./src/server/CursorAgentSdk.ts"
25 }
26 },
27 "scripts": {
28 "typecheck": "tsc --noEmit",
29 "test": "vp test run --config ../../vite.config.ts --dir ."
30 },
31 "dependencies": {
32 "@cursor/sdk": "1.0.35",
33 "@napi-rs/keyring": "^1.3.0",
34 "@t3tools/contracts": "workspace:*",
35 "@t3tools/provider-core": "workspace:*",
36 "@t3tools/shared": "workspace:*",
37 "effect": "catalog:",
38 "yaml": "catalog:"
39 },
40 "devDependencies": {
41 "@effect/platform-node": "catalog:",
42 "@effect/vitest": "catalog:",
43 "@t3tools/provider-testing": "workspace:*",
44 "@types/node": "catalog:",
45 "vite-plus": "catalog:"
46 }
47}
packages/provider-cursor/src/client.ts created+34
...@@ -0,0 +1,34 @@
1/**
2 * Cursor's client definition: settings form, label, sign-in environment
3 * field, and glyph. Browser- and React Native-safe.
4 *
5 * @module provider-cursor/client
6 */
7import { ProviderDriverKind } from "@t3tools/contracts";
8import { defineProviderClient } from "@t3tools/provider-core/client";
9
10import { CursorSettings } from "./settings.ts";
11
12export const cursorClient = defineProviderClient({
13 driverKind: ProviderDriverKind.make("cursor"),
14 label: "Cursor",
15 settingsSchema: CursorSettings,
16 environmentFields: [
17 {
18 name: "CURSOR_API_KEY",
19 label: "Cursor API key",
20 description: "Optional. Overrides browser sign-in for this provider.",
21 placeholder: "Paste API key",
22 sensitive: true,
23 },
24 ],
25 icon: {
26 viewBox: "0 0 466.73 532.09",
27 fill: { light: "#26251E", dark: "#EDECEC" },
28 paths: [
29 {
30 d: "M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z",
31 },
32 ],
33 },
34});
packages/provider-cursor/src/server.ts created+10
...@@ -0,0 +1,10 @@
1/**
2 * Cursor's server entry: the driver the server registers, its adapter
3 * driver, the SDK runner layer the server provides once, and the keychain
4 * token reader the usage scanner shares.
5 *
6 * @module provider-cursor/server
7 */
8export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts";
9export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts";
10export { CursorKeychainTimeoutError, readMacCursorAccessToken } from "./server/keychainToken.ts";
packages/provider-cursor/src/server/CursorAgentSdk.test.ts created+304
...@@ -0,0 +1,304 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { vi } from "vite-plus/test";
7
8import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
9import * as CursorAgentSdk from "./CursorAgentSdk.ts";
10
11const cursorSdkMock = vi.hoisted(() => {
12 const closeExecutionOrder: Array<string> = [];
13 const runOperations: Array<string> = [];
14 let agentCloseFailure: unknown;
15 let staleRunActive = false;
16 const runWait = vi.fn(async () => ({
17 id: "run-cursor-agent-sdk-test",
18 requestId: "request-cursor-agent-sdk-test",
19 status: "finished",
20 result: "ok",
21 model: { id: "default" },
22 durationMs: 1,
23 }));
24 const runCancel = vi.fn(async () => {});
25 const agentClose = vi.fn(() => {
26 closeExecutionOrder.push("sdk:agent.close");
27 if (agentCloseFailure !== undefined) {
28 throw agentCloseFailure;
29 }
30 });
31 const send = vi.fn(
32 async (
33 _message: unknown,
34 options: { readonly onDelta?: (input: { readonly update: unknown }) => Promise<void> },
35 ) => {
36 runOperations.push("run.start");
37 if (staleRunActive) {
38 throw new Error("Agent agent-cursor-agent-sdk-test already has active run");
39 }
40 await options.onDelta?.({
41 update: {
42 type: "assistant-message-chunk",
43 text: "hello",
44 },
45 });
46 return {
47 id: "run-cursor-agent-sdk-test",
48 agentId: "agent-cursor-agent-sdk-test",
49 wait: runWait,
50 cancel: runCancel,
51 };
52 },
53 );
54 const create = vi.fn(async () => ({
55 agentId: "agent-cursor-agent-sdk-test",
56 send,
57 close: agentClose,
58 }));
59 const resume = vi.fn(async () => ({
60 agentId: "agent-cursor-agent-sdk-test",
61 send,
62 close: agentClose,
63 }));
64 const listRuns = vi.fn(async () => {
65 runOperations.push("agent.listRuns");
66 return {
67 items: staleRunActive ? [{ id: "run-stale", status: "running" }] : [],
68 };
69 });
70 const cancelRun = vi.fn(async () => {
71 runOperations.push("agent.cancelRun");
72 staleRunActive = false;
73 });
74
75 return {
76 agentClose,
77 cancelRun,
78 closeExecutionOrder,
79 create,
80 listRuns,
81 resume,
82 runOperations,
83 runCancel,
84 runWait,
85 send,
86 setAgentCloseFailure: (failure: unknown) => {
87 agentCloseFailure = failure;
88 },
89 setStaleRunActive: (active: boolean) => {
90 staleRunActive = active;
91 },
92 };
93});
94
95vi.mock("./sdk.ts", () => ({
96 Agent: {
97 create: cursorSdkMock.create,
98 resume: cursorSdkMock.resume,
99 listRuns: cursorSdkMock.listRuns,
100 cancelRun: cursorSdkMock.cancelRun,
101 messages: {
102 list: vi.fn(async () => []),
103 },
104 },
105}));
106
107const layerTest = CursorAgentSdk.layer.pipe(
108 Layer.provide(
109 Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, {
110 native: {
111 filePath: "cursor-agent-sdk-test.log",
112 write: (event: unknown) =>
113 Effect.sync(() => {
114 if (
115 typeof event === "object" &&
116 event !== null &&
117 Reflect.get(Reflect.get(event, "event"), "payload")?.type === "agent.close"
118 ) {
119 cursorSdkMock.closeExecutionOrder.push("protocol:agent.close");
120 }
121 }),
122 close: () => Effect.void,
123 },
124 canonical: undefined,
125 }),
126 ),
127 Layer.provide(NodeServices.layer),
128);
129
130describe("CursorAgentSdkRunner", () => {
131 it.effect("leaves a resumed agent alone when its next send succeeds", () =>
132 Effect.gen(function* () {
133 cursorSdkMock.runOperations.length = 0;
134 cursorSdkMock.setStaleRunActive(false);
135
136 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
137 const session = yield* runner.open({
138 operation: "resume",
139 agentId: "agent-cursor-agent-sdk-test",
140 options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } },
141 threadId: ThreadId.make("thread-cursor-agent-sdk-normal-resume-test"),
142 providerSessionId: ProviderSessionId.make(
143 "provider-session-cursor-agent-sdk-normal-resume-test",
144 ),
145 });
146 const run = yield* session.send({ message: "continue" });
147 yield* run.wait;
148
149 assert.deepStrictEqual(cursorSdkMock.runOperations, ["run.start"]);
150 }).pipe(Effect.provide(layerTest)),
151 );
152
153 it.effect("retries a resumed send after cancelling its abandoned local run", () =>
154 Effect.gen(function* () {
155 cursorSdkMock.runOperations.length = 0;
156 cursorSdkMock.listRuns.mockClear();
157 cursorSdkMock.cancelRun.mockClear();
158 cursorSdkMock.setStaleRunActive(true);
159
160 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
161 const session = yield* runner.open({
162 operation: "resume",
163 agentId: "agent-cursor-agent-sdk-test",
164 options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } },
165 threadId: ThreadId.make("thread-cursor-agent-sdk-stale-run-test"),
166 providerSessionId: ProviderSessionId.make(
167 "provider-session-cursor-agent-sdk-stale-run-test",
168 ),
169 });
170 assert.deepStrictEqual(cursorSdkMock.runOperations, []);
171 const run = yield* session.send({ message: "continue" });
172 yield* run.wait;
173
174 assert.deepStrictEqual(cursorSdkMock.runOperations, [
175 "run.start",
176 "agent.listRuns",
177 "agent.cancelRun",
178 "run.start",
179 ]);
180 assert.deepStrictEqual(cursorSdkMock.listRuns.mock.calls[0], [
181 "agent-cursor-agent-sdk-test",
182 { runtime: "local", cwd: process.cwd(), limit: 1 },
183 ]);
184 assert.deepStrictEqual(cursorSdkMock.cancelRun.mock.calls[0], [
185 "run-stale",
186 {
187 runtime: "local",
188 cwd: process.cwd(),
189 },
190 ]);
191 }).pipe(Effect.provide(layerTest)),
192 );
193
194 it.effect("logs agent.close before invoking the Cursor SDK", () =>
195 Effect.gen(function* () {
196 cursorSdkMock.agentClose.mockClear();
197 cursorSdkMock.closeExecutionOrder.length = 0;
198 cursorSdkMock.setAgentCloseFailure(undefined);
199
200 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
201 const session = yield* runner.open({
202 operation: "create",
203 options: {
204 model: { id: "default" },
205 mode: "agent",
206 local: {
207 cwd: process.cwd(),
208 },
209 },
210 threadId: ThreadId.make("thread-cursor-agent-sdk-close-order-test"),
211 providerSessionId: ProviderSessionId.make(
212 "provider-session-cursor-agent-sdk-close-order-test",
213 ),
214 });
215
216 cursorSdkMock.closeExecutionOrder.length = 0;
217 yield* session.close;
218
219 assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [
220 "protocol:agent.close",
221 "sdk:agent.close",
222 ]);
223 assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1);
224 }).pipe(Effect.provide(layerTest)),
225 );
226
227 it.effect("logs agent.close even when the Cursor SDK close fails", () =>
228 Effect.gen(function* () {
229 cursorSdkMock.agentClose.mockClear();
230 cursorSdkMock.closeExecutionOrder.length = 0;
231 const closeFailure = new Error("agent close failed");
232 cursorSdkMock.setAgentCloseFailure(closeFailure);
233
234 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
235 const session = yield* runner.open({
236 operation: "create",
237 options: {
238 model: { id: "default" },
239 mode: "agent",
240 local: {
241 cwd: process.cwd(),
242 },
243 },
244 threadId: ThreadId.make("thread-cursor-agent-sdk-close-failure-test"),
245 providerSessionId: ProviderSessionId.make(
246 "provider-session-cursor-agent-sdk-close-failure-test",
247 ),
248 });
249
250 cursorSdkMock.closeExecutionOrder.length = 0;
251 const error = yield* Effect.flip(session.close);
252
253 assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError);
254 assert.equal(error.method, "agent.close");
255 assert.strictEqual(error.cause, closeFailure);
256 assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [
257 "protocol:agent.close",
258 "sdk:agent.close",
259 ]);
260 assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1);
261 cursorSdkMock.setAgentCloseFailure(undefined);
262 }).pipe(Effect.provide(layerTest)),
263 );
264
265 it.effect("surfaces interaction callback failures through run.wait", () =>
266 Effect.gen(function* () {
267 cursorSdkMock.create.mockClear();
268 cursorSdkMock.runWait.mockClear();
269 cursorSdkMock.send.mockClear();
270
271 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
272 const session = yield* runner.open({
273 operation: "create",
274 options: {
275 model: { id: "default" },
276 mode: "agent",
277 local: {
278 cwd: process.cwd(),
279 autoReview: false,
280 sandboxOptions: { enabled: false },
281 enableAgentRetries: true,
282 },
283 },
284 threadId: ThreadId.make("thread-cursor-agent-sdk-test"),
285 providerSessionId: ProviderSessionId.make("provider-session-cursor-agent-sdk-test"),
286 });
287
288 const callbackFailure = new Error("delta callback failed");
289 const run = yield* session.send({
290 message: "hello",
291 onDelta: () => Effect.fail(callbackFailure),
292 });
293
294 const error = yield* Effect.flip(run.wait);
295
296 assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError);
297 assert.equal(error.method, "run.wait");
298 assert.strictEqual(error.cause, callbackFailure);
299 assert.equal(cursorSdkMock.create.mock.calls.length, 1);
300 assert.equal(cursorSdkMock.send.mock.calls.length, 1);
301 assert.equal(cursorSdkMock.runWait.mock.calls.length, 1);
302 }).pipe(Effect.provide(layerTest)),
303 );
304});
packages/provider-cursor/src/server/CursorAgentSdk.ts created+611
...@@ -0,0 +1,611 @@
1import type {
2 AgentMessage,
3 AgentOptions,
4 InteractionUpdate,
5 RunResult,
6 SDKUserMessage,
7 SendOptions,
8} from "@cursor/sdk";
9import {
10 type OrchestrationV2ProviderSession,
11 ProviderDriverKind,
12 type ThreadId,
13} from "@t3tools/contracts";
14import * as Context from "effect/Context";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";
18
19import { Agent, createAgentPlatform } from "./sdk.ts";
20import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
21
22export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const;
23export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor");
24
25export class CursorAgentSdkRunnerError extends Schema.TaggedError<CursorAgentSdkRunnerError>()(
26 "CursorAgentSdkRunnerError",
27 {
28 method: Schema.String,
29 cause: Schema.Defect(),
30 },
31) {
32 override get message(): string {
33 return `Cursor Agent SDK ${this.method} failed.`;
34 }
35}
36
37const isCursorAgentSdkRunnerError = Schema.is(CursorAgentSdkRunnerError);
38
39export interface CursorAgentSdkOpenInput {
40 readonly operation: "create" | "resume";
41 readonly agentId?: string;
42 readonly options: AgentOptions;
43 readonly threadId: ThreadId;
44 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
45}
46
47export interface CursorAgentSdkSendInput<Error> {
48 readonly message: string | SDKUserMessage;
49 readonly options?: Omit<SendOptions, "onDelta">;
50 readonly onDelta?: (update: InteractionUpdate) => Effect.Effect<void, Error>;
51}
52
53export interface CursorAgentSdkRun {
54 readonly runId: string;
55 readonly agentId: string;
56 readonly wait: Effect.Effect<RunResult, CursorAgentSdkRunnerError>;
57 readonly cancel: Effect.Effect<void, CursorAgentSdkRunnerError>;
58}
59
60export interface CursorAgentSdkSession {
61 readonly agentId: string;
62 readonly send: <Error>(
63 input: CursorAgentSdkSendInput<Error>,
64 ) => Effect.Effect<CursorAgentSdkRun, CursorAgentSdkRunnerError>;
65 readonly listMessages: Effect.Effect<ReadonlyArray<AgentMessage>, CursorAgentSdkRunnerError>;
66 readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>;
67}
68
69export interface CursorAgentSdkRunnerShape {
70 readonly open: (
71 input: CursorAgentSdkOpenInput,
72 ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>;
73 readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>;
74}
75
76export class CursorAgentSdkRunner extends Context.Service<
77 CursorAgentSdkRunner,
78 CursorAgentSdkRunnerShape
79>()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {}
80
81export interface CursorAgentSdkLoggedAgentOptions {
82 readonly model?: AgentOptions["model"];
83 readonly hasName?: boolean;
84 readonly mode?: AgentOptions["mode"];
85 readonly local?: {
86 readonly hasCwd?: boolean;
87 readonly autoReview?: boolean;
88 readonly settingSources?: AgentOptions["local"] extends infer Local
89 ? Local extends { readonly settingSources?: infer Sources }
90 ? Sources
91 : never
92 : never;
93 readonly sandboxEnabled?: boolean;
94 readonly enableAgentRetries?: boolean;
95 readonly hasCustomTools?: boolean;
96 };
97 readonly agents?: ReadonlyArray<string>;
98}
99
100export interface CursorAgentSdkLoggedSendOptions {
101 readonly model?: SendOptions["model"];
102 readonly mode?: SendOptions["mode"];
103 readonly local?: {
104 readonly force?: boolean;
105 readonly hasCustomTools?: boolean;
106 };
107 readonly idempotencyKey?: string;
108}
109
110export type CursorAgentSdkProtocolLogEvent =
111 | {
112 readonly direction: "outgoing";
113 readonly stage: "decoded";
114 readonly payload: {
115 readonly type: "agent.open";
116 readonly operation: CursorAgentSdkOpenInput["operation"];
117 readonly agentId?: string;
118 readonly options: CursorAgentSdkLoggedAgentOptions;
119 };
120 }
121 | {
122 readonly direction: "incoming";
123 readonly stage: "decoded";
124 readonly payload: {
125 readonly type: "agent.opened";
126 readonly agentId: string;
127 };
128 }
129 | {
130 readonly direction: "outgoing";
131 readonly stage: "decoded";
132 readonly payload: {
133 readonly type: "run.start";
134 readonly message: string | SDKUserMessage;
135 readonly options: CursorAgentSdkLoggedSendOptions;
136 };
137 }
138 | {
139 readonly direction: "incoming";
140 readonly stage: "decoded";
141 readonly payload: {
142 readonly type: "run.started";
143 readonly runId: string;
144 readonly agentId: string;
145 };
146 }
147 | {
148 readonly direction: "incoming";
149 readonly stage: "decoded";
150 readonly payload: {
151 readonly type: "interaction.update";
152 readonly runId: string;
153 readonly update: InteractionUpdate;
154 };
155 }
156 | {
157 readonly direction: "incoming";
158 readonly stage: "decoded";
159 readonly payload: {
160 readonly type: "run.completed";
161 readonly result: RunResult;
162 };
163 }
164 | {
165 readonly direction: "outgoing";
166 readonly stage: "decoded";
167 readonly payload: {
168 readonly type: "run.cancel";
169 readonly runId: string;
170 };
171 }
172 | {
173 readonly direction: "outgoing";
174 readonly stage: "decoded";
175 readonly payload: {
176 readonly type: "agent.messages.list";
177 readonly agentId: string;
178 };
179 }
180 | {
181 readonly direction: "incoming";
182 readonly stage: "decoded";
183 readonly payload: {
184 readonly type: "agent.messages";
185 readonly agentId: string;
186 readonly messages: ReadonlyArray<AgentMessage>;
187 };
188 }
189 | {
190 readonly direction: "outgoing";
191 readonly stage: "decoded";
192 readonly payload: {
193 readonly type: "agent.close";
194 readonly agentId: string;
195 };
196 };
197
198export type CursorAgentSdkProtocolLogger = (
199 event: CursorAgentSdkProtocolLogEvent,
200) => Effect.Effect<void>;
201
202function runnerError(cause: unknown, method: string): CursorAgentSdkRunnerError {
203 return isCursorAgentSdkRunnerError(cause)
204 ? cause
205 : new CursorAgentSdkRunnerError({ method, cause });
206}
207
208function isActiveRunConflict(cause: unknown): boolean {
209 return cause instanceof Error && /already has active run/i.test(cause.message);
210}
211
212export function isCursorCancellationError(cause: unknown): boolean {
213 let current = cause;
214 const seen = new Set<object>();
215
216 while (typeof current === "object" && current !== null && !seen.has(current)) {
217 if (Reflect.get(current, "name") === "AbortError") {
218 return true;
219 }
220 seen.add(current);
221 current = Reflect.get(current, "cause");
222 }
223
224 return false;
225}
226
227export function loggedCursorAgentOptions(options: AgentOptions): CursorAgentSdkLoggedAgentOptions {
228 return {
229 ...(options.model === undefined ? {} : { model: options.model }),
230 ...(options.name === undefined ? {} : { hasName: true }),
231 ...(options.mode === undefined ? {} : { mode: options.mode }),
232 ...(options.local === undefined
233 ? {}
234 : {
235 local: {
236 ...(options.local.cwd === undefined ? {} : { hasCwd: true }),
237 ...(options.local.autoReview === undefined
238 ? {}
239 : { autoReview: options.local.autoReview }),
240 ...(options.local.settingSources === undefined
241 ? {}
242 : { settingSources: options.local.settingSources }),
243 ...(options.local.sandboxOptions === undefined
244 ? {}
245 : { sandboxEnabled: options.local.sandboxOptions.enabled }),
246 ...(options.local.enableAgentRetries === undefined
247 ? {}
248 : { enableAgentRetries: options.local.enableAgentRetries }),
249 ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }),
250 },
251 }),
252 ...(options.agents === undefined ? {} : { agents: Object.keys(options.agents).toSorted() }),
253 };
254}
255
256export function loggedCursorSendOptions(
257 options: Omit<SendOptions, "onDelta"> | undefined,
258): CursorAgentSdkLoggedSendOptions {
259 if (options === undefined) {
260 return {};
261 }
262 return {
263 ...(options.model === undefined ? {} : { model: options.model }),
264 ...(options.mode === undefined ? {} : { mode: options.mode }),
265 ...(options.local === undefined
266 ? {}
267 : {
268 local: {
269 ...(options.local.force === undefined ? {} : { force: options.local.force }),
270 ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }),
271 },
272 }),
273 ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }),
274 };
275}
276
277function makeCursorAgentSdkProtocolLogger(input: {
278 readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined;
279 readonly threadId: ThreadId;
280 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
281}): CursorAgentSdkProtocolLogger | undefined {
282 if (input.nativeEventLogger === undefined) {
283 return undefined;
284 }
285 const nativeEventLogger = input.nativeEventLogger;
286 return (event) =>
287 nativeEventLogger
288 .write(
289 {
290 provider: CURSOR_PROVIDER,
291 protocol: CURSOR_AGENT_SDK_PROTOCOL,
292 kind: "protocol",
293 providerSessionId: input.providerSessionId,
294 event,
295 },
296 input.threadId,
297 )
298 .pipe(Effect.ignore);
299}
300
301/**
302 * The Cursor SDK decides once per process whether local sandboxing works, and
303 * caches the answer the first time any run starts. Only sandboxed runs point
304 * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access)
305 * run it caches "unsupported" and rejects every later sandboxed run until the
306 * server restarts. Warming a bare sandboxed executor before the first
307 * unsandboxed agent opens lets the SDK find the helper and cache the real
308 * answer. Warming is best effort: on a machine without sandbox support it
309 * fails, the SDK caches "unsupported", and sandboxed runs report that as
310 * before.
311 */
312let cursorSandboxSupportPrime: Promise<void> | undefined;
313
314function primeCursorSandboxSupport(options: AgentOptions): Promise<void> {
315 cursorSandboxSupportPrime ??= (async () => {
316 const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined;
317 const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd });
318 const release = await platform.prewarmLocalWorkspace({
319 ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
320 local: {
321 ...(cwd === undefined ? {} : { cwd }),
322 settingSources: [],
323 sandboxOptions: { enabled: true },
324 },
325 });
326 await release();
327 })().catch(() => undefined);
328 return cursorSandboxSupportPrime;
329}
330
331/**
332 * Runs agents through the Cursor SDK, logging every frame to the protocol
333 * logger chosen for each opened agent. The live layer writes the native
334 * provider event log; the replay recorder turns the same frames into a
335 * transcript.
336 */
337export function makeCursorAgentSdkRunner(
338 protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined,
339): CursorAgentSdkRunnerShape {
340 return CursorAgentSdkRunner.of({
341 open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) {
342 if (input.options.local?.sandboxOptions?.enabled === false) {
343 yield* Effect.promise(() => primeCursorSandboxSupport(input.options));
344 }
345 const protocolLogger = protocolLoggerFor(input);
346 const log = (event: CursorAgentSdkProtocolLogEvent) =>
347 protocolLogger === undefined ? Effect.void : protocolLogger(event);
348
349 yield* log({
350 direction: "outgoing",
351 stage: "decoded",
352 payload: {
353 type: "agent.open",
354 operation: input.operation,
355 ...(input.agentId === undefined ? {} : { agentId: input.agentId }),
356 options: loggedCursorAgentOptions(input.options),
357 },
358 });
359
360 const agent = yield* Effect.tryPromise({
361 try: () =>
362 input.operation === "create"
363 ? Agent.create(input.options)
364 : Agent.resume(input.agentId!, input.options),
365 catch: (cause) => runnerError(cause, `agent.${input.operation}`),
366 });
367
368 yield* log({
369 direction: "incoming",
370 stage: "decoded",
371 payload: {
372 type: "agent.opened",
373 agentId: agent.agentId,
374 },
375 });
376
377 const cwd =
378 typeof input.options.local?.cwd === "string"
379 ? input.options.local.cwd
380 : input.options.local?.cwd?.[0];
381 const runOptions = {
382 runtime: "local" as const,
383 ...(cwd === undefined ? {} : { cwd }),
384 ...(input.options.local?.store === undefined ? {} : { store: input.options.local.store }),
385 };
386 let abandonedRunRecoveryAvailable = input.operation === "resume";
387
388 return {
389 agentId: agent.agentId,
390 send: Effect.fn("CursorAgentSdkSession.send")(function* (sendInput) {
391 const context = yield* Effect.context();
392 yield* log({
393 direction: "outgoing",
394 stage: "decoded",
395 payload: {
396 type: "run.start",
397 message: sendInput.message,
398 options: loggedCursorSendOptions(sendInput.options),
399 },
400 });
401
402 let callbacksReady = false;
403 const pendingUpdates: Array<InteractionUpdate> = [];
404 let callbackFailure: { readonly cause: unknown } | undefined;
405 let callbackChain = Promise.resolve();
406 let runId = "";
407 const dispatchUpdate = (update: InteractionUpdate): Promise<void> => {
408 callbackChain = callbackChain
409 .then(() => {
410 if (callbackFailure !== undefined) {
411 return;
412 }
413 return Effect.runPromiseWith(context)(
414 log({
415 direction: "incoming",
416 stage: "decoded",
417 payload: {
418 type: "interaction.update",
419 runId,
420 update,
421 },
422 }).pipe(Effect.andThen(sendInput.onDelta?.(update) ?? Effect.void)),
423 );
424 })
425 .catch((cause) => {
426 callbackFailure ??= { cause };
427 });
428 return callbackChain;
429 };
430
431 const startRun = () =>
432 Effect.tryPromise({
433 try: () =>
434 agent.send(sendInput.message, {
435 ...sendInput.options,
436 onDelta: async ({ update }) => {
437 if (!callbacksReady) {
438 pendingUpdates.push(update);
439 return;
440 }
441 await dispatchUpdate(update);
442 },
443 }),
444 catch: (cause) => runnerError(cause, "run.start"),
445 });
446 const run = yield* startRun().pipe(
447 Effect.catchIf(
448 (error) => abandonedRunRecoveryAvailable && isActiveRunConflict(error.cause),
449 (error) =>
450 Effect.gen(function* () {
451 abandonedRunRecoveryAvailable = false;
452 const latestRun = yield* Effect.tryPromise({
453 try: () => Agent.listRuns(agent.agentId, { ...runOptions, limit: 1 }),
454 catch: (cause) => runnerError(cause, "agent.listRuns"),
455 });
456 const activeRun = latestRun.items.find(
457 (candidate) => candidate.status === "running",
458 );
459 if (activeRun === undefined) {
460 return yield* error;
461 }
462 yield* log({
463 direction: "outgoing",
464 stage: "decoded",
465 payload: { type: "run.cancel", runId: activeRun.id },
466 });
467 yield* Effect.tryPromise({
468 try: () => Agent.cancelRun(activeRun.id, runOptions),
469 catch: (cause) => runnerError(cause, "agent.cancelRun"),
470 });
471 return yield* startRun();
472 }),
473 ),
474 );
475 abandonedRunRecoveryAvailable = false;
476 runId = run.id;
477 yield* log({
478 direction: "incoming",
479 stage: "decoded",
480 payload: {
481 type: "run.started",
482 runId: run.id,
483 agentId: run.agentId,
484 },
485 });
486 callbacksReady = true;
487 for (const update of pendingUpdates) {
488 yield* Effect.tryPromise({
489 try: () => dispatchUpdate(update),
490 catch: (cause) => runnerError(cause, "run.onDelta"),
491 });
492 }
493
494 return {
495 runId: run.id,
496 agentId: run.agentId,
497 wait: Effect.tryPromise({
498 try: async () => {
499 const result = await run.wait();
500 await callbackChain;
501 if (callbackFailure !== undefined) {
502 throw callbackFailure.cause;
503 }
504 return result;
505 },
506 catch: (cause) => runnerError(cause, "run.wait"),
507 }).pipe(
508 Effect.tap((result) =>
509 log({
510 direction: "incoming",
511 stage: "decoded",
512 payload: {
513 type: "run.completed",
514 result,
515 },
516 }),
517 ),
518 ),
519 cancel: log({
520 direction: "outgoing",
521 stage: "decoded",
522 payload: {
523 type: "run.cancel",
524 runId: run.id,
525 },
526 }).pipe(
527 Effect.andThen(
528 Effect.tryPromise({
529 try: async () => {
530 try {
531 await run.cancel();
532 } catch (cause) {
533 if (!isCursorCancellationError(cause)) {
534 throw cause;
535 }
536 }
537 },
538 catch: (cause) => runnerError(cause, "run.cancel"),
539 }),
540 ),
541 ),
542 } satisfies CursorAgentSdkRun;
543 }),
544 listMessages: log({
545 direction: "outgoing",
546 stage: "decoded",
547 payload: {
548 type: "agent.messages.list",
549 agentId: agent.agentId,
550 },
551 }).pipe(
552 Effect.andThen(
553 Effect.tryPromise({
554 try: () =>
555 Agent.messages.list(agent.agentId, {
556 runtime: "local",
557 ...(cwd === undefined ? {} : { cwd }),
558 }),
559 catch: (cause) => runnerError(cause, "agent.messages.list"),
560 }),
561 ),
562 Effect.tap((messages) =>
563 log({
564 direction: "incoming",
565 stage: "decoded",
566 payload: {
567 type: "agent.messages",
568 agentId: agent.agentId,
569 messages,
570 },
571 }),
572 ),
573 ),
574 close: log({
575 direction: "outgoing",
576 stage: "decoded",
577 payload: {
578 type: "agent.close",
579 agentId: agent.agentId,
580 },
581 }).pipe(
582 Effect.andThen(
583 Effect.try({
584 try: () => agent.close(),
585 catch: (cause) => runnerError(cause, "agent.close"),
586 }),
587 ),
588 ),
589 } satisfies CursorAgentSdkSession;
590 }),
591 assertComplete: Effect.void,
592 });
593}
594
595export const layer: Layer.Layer<
596 CursorAgentSdkRunner,
597 never,
598 ProviderEventLoggers.ProviderEventLoggers
599> = Layer.effect(
600 CursorAgentSdkRunner,
601 Effect.gen(function* () {
602 const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;
603 return makeCursorAgentSdkRunner((input) =>
604 makeCursorAgentSdkProtocolLogger({
605 nativeEventLogger,
606 threadId: input.threadId,
607 providerSessionId: input.providerSessionId,
608 }),
609 );
610 }),
611);
packages/provider-cursor/src/server/CursorSdkCatalog.test.ts created+66
...@@ -0,0 +1,66 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5
6import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
7
8const user = {
9 apiKeyName: "test-key",
10 userEmail: "cursor@example.com",
11 createdAt: "2026-01-01T00:00:00.000Z",
12} satisfies SDKUser;
13
14const model = {
15 id: "claude-opus-4-8",
16 displayName: "Opus 4.8",
17 parameters: [],
18} satisfies SDKModel;
19
20const catalogError = () =>
21 new CursorSdkCatalog.CursorSdkCatalogError({
22 authenticationFailure: false,
23 cause: new Error("catalog unavailable"),
24 });
25
26describe("CursorSdkCatalog", () => {
27 it.effect("keeps the user probe fresh while caching models by API key", () =>
28 Effect.gen(function* () {
29 let userCalls = 0;
30 let modelCalls = 0;
31 const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
32 readUser: () => Effect.sync(() => ((userCalls += 1), user)),
33 readModels: () => Effect.sync(() => ((modelCalls += 1), [model])),
34 });
35
36 yield* catalog.read("first-key");
37 yield* catalog.read("first-key");
38 yield* catalog.read("second-key");
39
40 expect(userCalls).toBe(3);
41 expect(modelCalls).toBe(2);
42 }),
43 );
44
45 it.effect("does not cache failed or empty model discovery", () =>
46 Effect.gen(function* () {
47 let modelCalls = 0;
48 const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({
49 readUser: () => Effect.succeed(user),
50 readModels: () =>
51 Effect.suspend(() => {
52 modelCalls += 1;
53 if (modelCalls === 1) return Effect.fail(catalogError());
54 if (modelCalls === 2) return Effect.succeed([]);
55 return Effect.succeed([model]);
56 }),
57 });
58
59 expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true);
60 expect((yield* catalog.read("test-key")).models).toEqual([]);
61 expect((yield* catalog.read("test-key")).models).toEqual([model]);
62 expect((yield* catalog.read("test-key")).models).toEqual([model]);
63 expect(modelCalls).toBe(3);
64 }),
65 );
66});
packages/provider-cursor/src/server/CursorSdkCatalog.ts created+92
...@@ -0,0 +1,92 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9
10import { AuthenticationError, Cursor, CursorSdkError } from "./sdk.ts";
11
12export interface CursorSdkCatalogSnapshot {
13 readonly user: SDKUser;
14 readonly models: ReadonlyArray<SDKModel>;
15}
16
17export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()(
18 "CursorSdkCatalogError",
19 {
20 authenticationFailure: Schema.Boolean,
21 cause: Schema.Defect(),
22 },
23) {
24 override get message(): string {
25 return this.authenticationFailure
26 ? "Cursor SDK authentication failed."
27 : "Cursor SDK catalog request failed.";
28 }
29}
30
31export interface CursorSdkCatalogShape {
32 readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>;
33}
34
35export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()(
36 "@t3tools/provider-cursor/server/CursorSdkCatalog",
37) {}
38
39function isAuthenticationFailure(cause: unknown): boolean {
40 return (
41 cause instanceof AuthenticationError ||
42 (cause instanceof CursorSdkError && cause.status === 401)
43 );
44}
45
46export interface CursorSdkCatalogProbes {
47 readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>;
48 readonly readModels: (
49 apiKey: string,
50 ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>;
51}
52
53const sdkRequest = <A>(tryRequest: () => Promise<A>) =>
54 Effect.tryPromise({
55 try: tryRequest,
56 catch: (cause) =>
57 new CursorSdkCatalogError({
58 authenticationFailure: isAuthenticationFailure(cause),
59 cause,
60 }),
61 });
62
63const liveProbes: CursorSdkCatalogProbes = {
64 readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })),
65 readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })),
66};
67
68export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* (
69 probes: CursorSdkCatalogProbes = liveProbes,
70) {
71 const modelCache = yield* Cache.makeWith(probes.readModels, {
72 capacity: 32,
73 timeToLive: (exit) =>
74 Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
75 });
76 return CursorSdkCatalog.of({
77 read: (apiKey) =>
78 Effect.all(
79 {
80 user: probes.readUser(apiKey),
81 models: Cache.get(modelCache, apiKey),
82 },
83 { concurrency: "unbounded" },
84 ),
85 });
86});
87
88export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog());
89
90export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> {
91 return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read }));
92}
packages/provider-cursor/src/server/adapter.test.ts created+955
...@@ -0,0 +1,955 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import type { InteractionUpdate } from "@cursor/sdk";
4import {
5 EnvironmentId,
6 MessageId,
7 NodeId,
8 ProjectId,
9 ProviderInstanceId,
10 ProviderSessionId,
11 RunAttemptId,
12 RunId,
13 ThreadId,
14} from "@t3tools/contracts";
15import { CursorSettings } from "../settings.ts";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
24
25import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
26import { layerTestProviderHost } from "@t3tools/provider-testing/host";
27import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
28import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
29import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
30import {
31 cursorMcpServers,
32 cursorRuntimeAgentPolicy,
33 cursorSdkModelSelection,
34 makeCursorAgentOptions,
35 makeCursorAdapterV2,
36 nestedToolCallFromEnvelope,
37} from "./adapter.ts";
38import * as CursorAgentSdk from "./CursorAgentSdk.ts";
39
40const decodeCursorSettings = Schema.decodeEffect(CursorSettings);
41
42describe("CursorAdapterV2", () => {
43 it.effect.each([
44 { status: "finished", model: undefined, lateModel: undefined },
45 { status: "cancelled", model: "claude-opus-4-6", lateModel: undefined },
46 { status: "error", model: "custom-fable", lateModel: undefined },
47 { status: "finished", model: undefined, lateModel: "gpt-6-sol" },
48 { status: "finished", model: "gpt-6-sol", lateModel: null },
49 ] as const)(
50 "projects Cursor tasks: $status, late model $lateModel",
51 ({ status, model, lateModel }) =>
52 Effect.gen(function* () {
53 const fileSystem = yield* FileSystem.FileSystem;
54 const path = yield* Path.Path;
55 const workspace = yield* fileSystem.makeTempDirectoryScoped({
56 prefix: "cursor-v2-lifecycle-",
57 });
58 const instanceId = ProviderInstanceId.make("cursor");
59 const threadId = ThreadId.make("cursor-lifecycle-thread");
60 const modelSelection = { instanceId, model: "composer-2.5" };
61 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
62 runtimeMode: "full-access",
63 interactionMode: "default",
64 cwd: workspace,
65 });
66 const adapter = makeCursorAdapterV2({
67 instanceId,
68 settings: yield* decodeCursorSettings({}),
69 environment: { HOME: workspace },
70 fileSystem,
71 path,
72 idAllocator: yield* IdAllocator.IdAllocatorV2,
73 host: yield* ProviderHost.ProviderHost.pipe(
74 Effect.provide(layerTestProviderHost({ cwd: workspace })),
75 ),
76 runner: {
77 assertComplete: Effect.void,
78 open: () =>
79 Effect.succeed({
80 agentId: "native-cursor-lifecycle",
81 listMessages: Effect.succeed([]),
82 close: Effect.void,
83 send: (input) =>
84 Effect.gen(function* () {
85 // Recorded Cursor task calls (see fixtures/subagent) stream a
86 // partial-tool-call before tool-call-started with the same args.
87 // The run then ends without tool-call-completed, which a live
88 // run cannot produce on demand.
89 const taskToolCall = {
90 type: "task" as const,
91 args: {
92 description: "Review",
93 prompt: "Review the code.",
94 subagentType: { kind: "unspecified" },
95 ...(model === undefined ? {} : { model }),
96 agentId: "cursor-task-agent",
97 mode: "unspecified" as const,
98 },
99 };
100 const updates =
101 lateModel !== undefined
102 ? (["tool-call-started", "tool-call-completed"] as const)
103 : (["partial-tool-call", "tool-call-started"] as const);
104 for (const type of updates) {
105 yield* input.onDelta!({
106 type,
107 modelCallId: "model-call",
108 callId: "task-call",
109 toolCall: {
110 ...taskToolCall,
111 args: {
112 ...taskToolCall.args,
113 ...(type === "tool-call-completed"
114 ? { model: lateModel ?? undefined }
115 : {}),
116 },
117 },
118 }).pipe(Effect.orDie);
119 }
120 return {
121 agentId: "native-cursor-lifecycle",
122 runId: "native-cursor-run",
123 wait: Effect.succeed({
124 id: "native-cursor-run",
125 requestId: "native-request",
126 status,
127 model: { id: "composer-2.5" },
128 durationMs: 1,
129 }),
130 cancel: Effect.void,
131 };
132 }),
133 }),
134 },
135 });
136 const runtime = yield* adapter.openSession({
137 threadId,
138 providerSessionId: ProviderSessionId.make("cursor-lifecycle-session"),
139 modelSelection,
140 runtimePolicy,
141 });
142 const providerThread = yield* runtime.ensureThread({
143 threadId,
144 modelSelection,
145 runtimePolicy,
146 });
147 const now = yield* DateTime.now;
148 yield* runtime.startTurn({
149 threadId,
150 providerThread,
151 modelSelection,
152 runtimePolicy,
153 runId: RunId.make("cursor-lifecycle-run"),
154 runOrdinal: 1,
155 providerTurnOrdinal: 1,
156 attemptId: RunAttemptId.make("cursor-lifecycle-attempt"),
157 rootNodeId: NodeId.make("cursor-lifecycle-root"),
158 appThread: {
159 id: threadId,
160 projectId: ProjectId.make("cursor-lifecycle-project"),
161 createdBy: "user",
162 creationSource: "web",
163 title: "Cursor lifecycle",
164 providerInstanceId: instanceId,
165 modelSelection,
166 runtimeMode: "full-access",
167 interactionMode: "default",
168 branch: null,
169 worktreePath: null,
170 activeProviderThreadId: providerThread.id,
171 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
172 forkedFrom: null,
173 createdAt: now,
174 updatedAt: now,
175 archivedAt: null,
176 settledOverride: null,
177 settledAt: null,
178 lastVisitedAt: null,
179 deletedAt: null,
180 },
181 message: {
182 messageId: MessageId.make("cursor-lifecycle-message"),
183 createdBy: "user",
184 creationSource: "web",
185 text: "Review the code.",
186 attachments: [],
187 },
188 });
189 const events = yield* runtime.events.pipe(
190 Stream.takeUntil((event) => event.type === "turn.terminal"),
191 Stream.runCollect,
192 );
193 const rows = events.filter((event) => event.type === "subagent.updated");
194 assert.equal(rows[0]?.subagent.status, "running");
195 assert.equal(rows[0]?.subagent.model, model ?? null);
196 assert.equal(rows.at(-1)?.subagent.model, lateModel ?? model ?? null);
197 assert.equal(
198 rows.at(-1)?.subagent.status,
199 lateModel !== undefined
200 ? "completed"
201 : status === "finished"
202 ? "idle"
203 : status === "cancelled"
204 ? "cancelled"
205 : "failed",
206 );
207 assert.isNotNull(rows.at(-1)?.subagent.completedAt);
208 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
209 );
210
211 it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () =>
212 Effect.gen(function* () {
213 const fileSystem = yield* FileSystem.FileSystem;
214 const path = yield* Path.Path;
215 const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" });
216 const sentMessages: Array<string> = [];
217 let sendCalls = 0;
218 const instanceId = ProviderInstanceId.make("cursor");
219 const threadId = ThreadId.make("cursor-transport-error-thread");
220 const modelSelection = { instanceId, model: "composer-2.5" };
221 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
222 runtimeMode: "full-access",
223 interactionMode: "default",
224 cwd: workspace,
225 });
226 const adapter = makeCursorAdapterV2({
227 instanceId,
228 settings: yield* decodeCursorSettings({}),
229 environment: { HOME: workspace },
230 fileSystem,
231 path,
232 idAllocator: yield* IdAllocator.IdAllocatorV2,
233 host: yield* ProviderHost.ProviderHost.pipe(
234 Effect.provide(layerTestProviderHost({ cwd: workspace })),
235 ),
236 runner: {
237 assertComplete: Effect.void,
238 open: () =>
239 Effect.succeed({
240 agentId: "native-cursor-error",
241 listMessages: Effect.succeed([]),
242 close: Effect.void,
243 send: (input) =>
244 Effect.sync(() => {
245 sendCalls += 1;
246 sentMessages.push(
247 typeof input.message === "string" ? input.message : input.message.text,
248 );
249 const runId = `native-cursor-run-${sendCalls}`;
250 return {
251 agentId: "native-cursor-error",
252 runId,
253 wait: Effect.succeed({
254 id: runId,
255 requestId: `native-request-${sendCalls}`,
256 status: "finished" as const,
257 model: { id: "composer-2.5" },
258 durationMs: 1,
259 ...(sendCalls === 1
260 ? { result: "Error: RetriableError: WritableIterable is closed" }
261 : {}),
262 }),
263 cancel: Effect.void,
264 };
265 }),
266 }),
267 },
268 });
269 const runtime = yield* adapter.openSession({
270 threadId,
271 providerSessionId: ProviderSessionId.make("cursor-error-session"),
272 modelSelection,
273 runtimePolicy,
274 });
275 const providerThread = yield* runtime.ensureThread({
276 threadId,
277 modelSelection,
278 runtimePolicy,
279 });
280 const now = yield* DateTime.now;
281 const appThread = {
282 id: threadId,
283 projectId: ProjectId.make("cursor-error-project"),
284 createdBy: "user" as const,
285 creationSource: "web" as const,
286 title: "Cursor transport failure",
287 providerInstanceId: instanceId,
288 modelSelection,
289 runtimeMode: "full-access" as const,
290 interactionMode: "default" as const,
291 branch: null,
292 worktreePath: null,
293 activeProviderThreadId: providerThread.id,
294 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
295 forkedFrom: null,
296 createdAt: now,
297 updatedAt: now,
298 archivedAt: null,
299 settledOverride: null,
300 settledAt: null,
301 lastVisitedAt: null,
302 deletedAt: null,
303 };
304 yield* runtime.startTurn({
305 threadId,
306 providerThread,
307 modelSelection,
308 runtimePolicy,
309 runId: RunId.make("cursor-error-run"),
310 runOrdinal: 1,
311 providerTurnOrdinal: 1,
312 attemptId: RunAttemptId.make("cursor-error-attempt"),
313 rootNodeId: NodeId.make("cursor-error-root"),
314 appThread,
315 message: {
316 messageId: MessageId.make("cursor-error-message"),
317 createdBy: "user",
318 creationSource: "web",
319 text: "continue",
320 attachments: [],
321 },
322 });
323 const first = yield* runtime.events.pipe(
324 Stream.filter((event) => event.type === "turn.terminal"),
325 Stream.runHead,
326 );
327 assert.isTrue(Option.isSome(first));
328 if (Option.isSome(first)) {
329 assert.equal(first.value.status, "failed");
330 assert.equal(first.value.failure?.class, "transport_error");
331 }
332
333 assert.isDefined(runtime.compactThread);
334 yield* runtime.compactThread!({
335 threadId,
336 providerThread,
337 modelSelection,
338 runtimePolicy,
339 runId: RunId.make("cursor-compact-run"),
340 runOrdinal: 2,
341 providerTurnOrdinal: 2,
342 attemptId: RunAttemptId.make("cursor-compact-attempt"),
343 rootNodeId: NodeId.make("cursor-compact-root"),
344 appThread,
345 message: {
346 messageId: MessageId.make("cursor-compact-message"),
347 createdBy: "user",
348 creationSource: "web",
349 text: "ignored by compactThread",
350 attachments: [],
351 },
352 });
353 yield* runtime.events.pipe(
354 Stream.filter((event) => event.type === "turn.terminal"),
355 Stream.runHead,
356 );
357 assert.equal(sentMessages[1], "/compress");
358 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
359 );
360
361 it.effect("projects Cursor directory trees and lint diagnostics as file search results", () =>
362 Effect.gen(function* () {
363 const fileSystem = yield* FileSystem.FileSystem;
364 const path = yield* Path.Path;
365 const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-search-" });
366 const instanceId = ProviderInstanceId.make("cursor");
367 const threadId = ThreadId.make("cursor-search-thread");
368 const modelSelection = { instanceId, model: "composer-2.5" };
369 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
370 runtimeMode: "full-access",
371 interactionMode: "default",
372 cwd: workspace,
373 });
374 const emptyLsNode = {
375 childrenDirs: [],
376 childrenFiles: [],
377 childrenWereProcessed: true,
378 fullSubtreeExtensionCounts: {},
379 numFiles: 0,
380 };
381 const updates: ReadonlyArray<InteractionUpdate> = [
382 ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({
383 type,
384 modelCallId: "native-model-call",
385 callId: "mcp-weather",
386 toolCall: {
387 type: "mcp" as const,
388 args: {
389 providerIdentifier: "weather",
390 toolName: "get_weather",
391 args: { city: "Berlin" },
392 },
393 ...(type === "tool-call-completed"
394 ? { result: { status: "success" as const, value: { content: [], isError: false } } }
395 : {}),
396 },
397 })),
398 {
399 type: "tool-call-completed",
400 modelCallId: "native-model-call",
401 callId: "read-file",
402 toolCall: {
403 type: "read",
404 args: { path: "src/env.ts" },
405 result: {
406 status: "success",
407 value: { fileSize: 12, content: "---\nfile body", totalLines: 2 },
408 },
409 },
410 },
411 {
412 type: "tool-call-completed",
413 modelCallId: "native-model-call",
414 callId: "ls-nested",
415 toolCall: {
416 type: "ls",
417 args: { path: workspace },
418 result: {
419 status: "success",
420 value: {
421 directoryTreeRoot: {
422 ...emptyLsNode,
423 absPath: workspace,
424 childrenFiles: [{ name: "README.md" }],
425 childrenDirs: [
426 {
427 ...emptyLsNode,
428 absPath: path.join(workspace, "src"),
429 childrenFiles: [{ name: "index.ts" }],
430 childrenDirs: [
431 {
432 ...emptyLsNode,
433 absPath: path.join(workspace, "src", "nested"),
434 childrenFiles: [{ name: "util.ts" }],
435 },
436 ],
437 },
438 ],
439 },
440 },
441 },
442 },
443 },
444 {
445 type: "tool-call-completed",
446 modelCallId: "native-model-call",
447 callId: "ls-empty",
448 toolCall: {
449 type: "ls",
450 args: { path: path.join(workspace, "empty") },
451 result: {
452 status: "success",
453 value: {
454 directoryTreeRoot: { ...emptyLsNode, absPath: path.join(workspace, "empty") },
455 },
456 },
457 },
458 },
459 {
460 type: "tool-call-completed",
461 modelCallId: "native-model-call",
462 callId: "ls-failed",
463 toolCall: {
464 type: "ls",
465 args: { path: path.join(workspace, "missing") },
466 result: { status: "error", error: "ENOENT" },
467 },
468 },
469 {
470 type: "tool-call-completed",
471 modelCallId: "native-model-call",
472 callId: "grep-failed",
473 toolCall: {
474 type: "grep",
475 args: { pattern: "TODO", path: "src" },
476 result: { status: "error", error: "search failed" },
477 },
478 },
479 {
480 type: "tool-call-completed",
481 modelCallId: "native-model-call",
482 callId: "glob-failed",
483 toolCall: {
484 type: "glob",
485 args: { globPattern: "*.ts" },
486 result: { status: "error", error: "search failed" },
487 },
488 },
489 {
490 type: "tool-call-completed",
491 modelCallId: "native-model-call",
492 callId: "lints",
493 toolCall: {
494 type: "readLints",
495 args: { paths: ["src/a.ts", "src/b.ts"] },
496 result: {
497 status: "success",
498 value: {
499 totalFiles: 2,
500 totalDiagnostics: 3,
501 fileDiagnostics: [
502 {
503 path: "src/a.ts",
504 diagnosticsCount: 2,
505 diagnostics: [
506 {
507 message: "Unused variable",
508 code: "TS6133",
509 source: "ts",
510 severity: "warning",
511 range: { start: { line: 0, character: 4 } },
512 },
513 {
514 message: "Cannot find name",
515 code: "TS2304",
516 source: "ts",
517 severity: "error",
518 range: { start: { line: 11 } },
519 },
520 ],
521 },
522 {
523 path: "src/b.ts",
524 diagnosticsCount: 1,
525 diagnostics: [
526 {
527 message: "File-level diagnostic",
528 code: "TS0",
529 source: "ts",
530 severity: "information",
531 },
532 ],
533 },
534 ],
535 },
536 },
537 },
538 },
539 {
540 type: "tool-call-completed",
541 modelCallId: "native-model-call",
542 callId: "lints-empty",
543 toolCall: {
544 type: "readLints",
545 args: { paths: ["src/clean.ts"] },
546 result: {
547 status: "success",
548 value: {
549 totalFiles: 1,
550 totalDiagnostics: 0,
551 fileDiagnostics: [{ path: "src/clean.ts", diagnosticsCount: 0, diagnostics: [] }],
552 },
553 },
554 },
555 },
556 {
557 type: "tool-call-completed",
558 modelCallId: "native-model-call",
559 callId: "lints-failed",
560 toolCall: {
561 type: "readLints",
562 args: { paths: ["src/a.ts"] },
563 result: { status: "error", error: "lint failed" },
564 },
565 },
566 ];
567 const adapter = makeCursorAdapterV2({
568 instanceId,
569 settings: yield* decodeCursorSettings({}),
570 environment: { HOME: workspace },
571 fileSystem,
572 path,
573 idAllocator: yield* IdAllocator.IdAllocatorV2,
574 host: yield* ProviderHost.ProviderHost.pipe(
575 Effect.provide(layerTestProviderHost({ cwd: workspace })),
576 ),
577 runner: {
578 assertComplete: Effect.void,
579 open: () =>
580 Effect.succeed({
581 agentId: "native-cursor-search",
582 listMessages: Effect.succeed([]),
583 close: Effect.void,
584 send: (input) =>
585 Effect.gen(function* () {
586 for (const update of updates) {
587 if (input.onDelta !== undefined) {
588 yield* input.onDelta(update).pipe(Effect.orDie);
589 }
590 }
591 return {
592 agentId: "native-cursor-search",
593 runId: "native-cursor-run",
594 wait: Effect.succeed({
595 id: "native-cursor-run",
596 requestId: "native-request",
597 status: "finished" as const,
598 model: { id: "composer-2.5" },
599 durationMs: 1,
600 }),
601 cancel: Effect.void,
602 };
603 }),
604 }),
605 },
606 });
607 const runtime = yield* adapter.openSession({
608 threadId,
609 providerSessionId: ProviderSessionId.make("cursor-search-session"),
610 modelSelection,
611 runtimePolicy,
612 });
613 const providerThread = yield* runtime.ensureThread({
614 threadId,
615 modelSelection,
616 runtimePolicy,
617 });
618 const now = yield* DateTime.now;
619 yield* runtime.startTurn({
620 threadId,
621 providerThread,
622 modelSelection,
623 runtimePolicy,
624 runId: RunId.make("cursor-search-run"),
625 runOrdinal: 1,
626 providerTurnOrdinal: 1,
627 attemptId: RunAttemptId.make("cursor-search-attempt"),
628 rootNodeId: NodeId.make("cursor-search-root"),
629 appThread: {
630 id: threadId,
631 projectId: ProjectId.make("cursor-search-project"),
632 createdBy: "user",
633 creationSource: "web",
634 title: "Cursor search results",
635 providerInstanceId: instanceId,
636 modelSelection,
637 runtimeMode: "full-access",
638 interactionMode: "default",
639 branch: null,
640 worktreePath: null,
641 activeProviderThreadId: providerThread.id,
642 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
643 forkedFrom: null,
644 createdAt: now,
645 updatedAt: now,
646 archivedAt: null,
647 settledOverride: null,
648 settledAt: null,
649 lastVisitedAt: null,
650 deletedAt: null,
651 },
652 message: {
653 messageId: MessageId.make("cursor-search-message"),
654 createdBy: "user",
655 creationSource: "web",
656 text: "inspect the workspace",
657 attachments: [],
658 },
659 });
660 const events = yield* runtime.events.pipe(
661 Stream.takeUntil((event) => event.type === "turn.terminal"),
662 Stream.runCollect,
663 );
664 const mcpItems = events.flatMap((event) =>
665 event.type === "turn_item.updated" &&
666 event.turnItem.type === "dynamic_tool" &&
667 event.turnItem.toolName === "mcp__weather__get_weather"
668 ? [event.turnItem]
669 : [],
670 );
671 assert.deepEqual(
672 mcpItems.map((item) => item.status),
673 ["running", "completed"],
674 );
675 for (const item of mcpItems) {
676 assert.equal(item.title, "get weather");
677 assert.deepEqual(item.toolSource, {
678 key: "mcp:weather",
679 name: "weather",
680 kind: "integration",
681 });
682 assert.deepEqual(item.input, {
683 providerIdentifier: "weather",
684 toolName: "get_weather",
685 args: { city: "Berlin" },
686 });
687 }
688 const fileSearchItems = events.flatMap((event) =>
689 event.type === "turn_item.updated" &&
690 event.turnItem.type === "file_search" &&
691 event.turnItem.status !== "running"
692 ? [event.turnItem]
693 : [],
694 );
695 const readItems = events.flatMap((event) =>
696 event.type === "turn_item.updated" &&
697 event.turnItem.type === "dynamic_tool" &&
698 event.turnItem.toolName === "Read" &&
699 event.turnItem.status === "completed"
700 ? [event.turnItem]
701 : [],
702 );
703 assert.deepEqual(
704 readItems.map((item) => ({ title: item.title, input: item.input })),
705 [{ title: "Read src/env.ts", input: { path: "src/env.ts" } }],
706 );
707 assert.deepEqual(
708 fileSearchItems.map((item) => ({
709 pattern: item.pattern,
710 status: item.status,
711 results: item.results,
712 })),
713 [
714 {
715 pattern: workspace,
716 status: "completed",
717 results: [
718 { fileName: path.join(workspace, "README.md") },
719 { fileName: path.join(workspace, "src", "index.ts") },
720 { fileName: path.join(workspace, "src", "nested", "util.ts") },
721 ],
722 },
723 {
724 pattern: path.join(workspace, "empty"),
725 status: "completed",
726 results: undefined,
727 },
728 {
729 pattern: path.join(workspace, "missing"),
730 status: "failed",
731 results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }],
732 },
733 {
734 pattern: "TODO",
735 status: "failed",
736 results: [{ fileName: "src", preview: "search failed" }],
737 },
738 {
739 pattern: "*.ts",
740 status: "failed",
741 results: [{ fileName: ".", preview: "search failed" }],
742 },
743 {
744 pattern: "src/a.ts, src/b.ts",
745 status: "completed",
746 results: [
747 { fileName: "src/a.ts", line: 1, column: 5, preview: "Unused variable" },
748 { fileName: "src/a.ts", line: 12, preview: "Cannot find name" },
749 { fileName: "src/b.ts", preview: "File-level diagnostic" },
750 ],
751 },
752 {
753 pattern: "src/clean.ts",
754 status: "completed",
755 results: undefined,
756 },
757 {
758 pattern: "src/a.ts",
759 status: "failed",
760 results: [{ fileName: "src/a.ts", preview: "lint failed" }],
761 },
762 ],
763 );
764 }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
765 );
766
767 it("maps Cursor auto and model parameters to SDK selections", () => {
768 assert.deepEqual(
769 cursorSdkModelSelection({
770 instanceId: ProviderInstanceId.make("cursor"),
771 model: "auto",
772 options: [
773 { id: "thinking", value: "high" },
774 { id: "contextWindow", value: "1m" },
775 { id: "fastMode", value: true },
776 ],
777 }),
778 {
779 id: "default",
780 params: [
781 { id: "thinking", value: "high" },
782 { id: "context", value: "1m" },
783 { id: "fast", value: "true" },
784 ],
785 },
786 );
787 });
788
789 it("maps runtime modes to the SDK sandbox and auto-review controls", () => {
790 const base = {
791 interactionMode: "default" as const,
792 cwd: "/tmp/cursor-adapter",
793 };
794 assert.deepEqual(
795 cursorRuntimeAgentPolicy({
796 ...base,
797 runtimeMode: "full-access",
798 }),
799 {
800 autoReview: false,
801 sandboxEnabled: false,
802 },
803 );
804 assert.deepEqual(
805 cursorRuntimeAgentPolicy({
806 ...base,
807 runtimeMode: "auto-accept-edits",
808 }),
809 {
810 autoReview: false,
811 sandboxEnabled: true,
812 },
813 );
814 assert.deepEqual(
815 cursorRuntimeAgentPolicy({
816 ...base,
817 runtimeMode: "approval-required",
818 }),
819 {
820 autoReview: true,
821 sandboxEnabled: true,
822 },
823 );
824 assert.deepEqual(
825 cursorRuntimeAgentPolicy({
826 ...base,
827 runtimeMode: "full-access",
828 approvalPolicy: "never",
829 sandboxPolicy: { type: "readOnly" },
830 }),
831 {
832 autoReview: false,
833 sandboxEnabled: true,
834 },
835 );
836 assert.deepEqual(
837 cursorRuntimeAgentPolicy({
838 ...base,
839 runtimeMode: "approval-required",
840 approvalPolicy: "never",
841 sandboxPolicy: { type: "dangerFullAccess" },
842 }),
843 {
844 autoReview: false,
845 sandboxEnabled: false,
846 },
847 );
848 });
849
850 it("loads the user's Cursor settings layers in every runtime mode", () => {
851 // The SDK loads no rules, skills, hooks, or MCP config from disk unless
852 // settingSources names them, so an omitted list silently drops AGENTS.md.
853 for (const runtimeMode of ["full-access", "auto-accept-edits", "approval-required"] as const) {
854 const options = makeCursorAgentOptions({
855 modelSelection: {
856 instanceId: ProviderInstanceId.make("cursor"),
857 model: "composer-2.5",
858 },
859 runtimePolicy: { runtimeMode, interactionMode: "default", cwd: "/workspace" },
860 threadId: ThreadId.make("thread-cursor-setting-sources"),
861 });
862 assert.deepEqual(options.local?.settingSources, [
863 "project",
864 "user",
865 "team",
866 "mdm",
867 "plugins",
868 ]);
869 }
870 });
871
872 it("injects thread-scoped MCP credentials without logging them", () => {
873 const threadId = ThreadId.make("thread-cursor-mcp");
874 McpProviderSession.setMcpProviderSession({
875 environmentId: EnvironmentId.make("environment-cursor-mcp"),
876 threadId,
877 providerSessionId: "mcp-session-cursor",
878 providerInstanceId: ProviderInstanceId.make("cursor"),
879 endpoint: "http://127.0.0.1:43123/mcp",
880 authorizationHeader: "Bearer secret-cursor-mcp-token",
881 browserToolsAvailable: true,
882 });
883
884 try {
885 assert.deepEqual(cursorMcpServers(threadId), {
886 "t3-code": {
887 type: "http",
888 url: "http://127.0.0.1:43123/mcp",
889 headers: {
890 Authorization: "Bearer secret-cursor-mcp-token",
891 },
892 },
893 });
894
895 const options = makeCursorAgentOptions({
896 apiKey: "secret-cursor-api-key",
897 modelSelection: {
898 instanceId: ProviderInstanceId.make("cursor"),
899 model: "composer-2.5",
900 },
901 runtimePolicy: {
902 runtimeMode: "full-access",
903 interactionMode: "default",
904 cwd: "/workspace",
905 },
906 threadId,
907 });
908 assert.deepEqual(options.mcpServers, cursorMcpServers(threadId));
909
910 const logged = JSON.stringify(CursorAgentSdk.loggedCursorAgentOptions(options));
911 assert.notInclude(logged, "secret-cursor-api-key");
912 assert.notInclude(logged, "secret-cursor-mcp-token");
913 } finally {
914 McpProviderSession.clearMcpProviderSession(threadId);
915 }
916 });
917
918 it("recognizes direct and SDK-wrapped abort failures as cancellation", () => {
919 assert.isTrue(CursorAgentSdk.isCursorCancellationError({ name: "AbortError" }));
920 assert.isTrue(
921 CursorAgentSdk.isCursorCancellationError({
922 name: "ConnectError",
923 cause: {
924 name: "ConnectError",
925 cause: { name: "AbortError" },
926 },
927 }),
928 );
929 assert.isFalse(CursorAgentSdk.isCursorCancellationError(new Error("request failed")));
930 assert.isFalse(CursorAgentSdk.isCursorCancellationError(null));
931 });
932
933 it("preserves failed nested read calls when Cursor omits their path", () => {
934 assert.deepEqual(
935 nestedToolCallFromEnvelope({
936 toolCallId: "tool:failed-read",
937 readToolCall: {
938 args: {},
939 result: { error: "File path was not provided." },
940 },
941 }),
942 {
943 callId: "tool:failed-read",
944 toolCall: {
945 type: "read",
946 args: { path: "<unknown path>" },
947 result: {
948 status: "error",
949 error: "File path was not provided.",
950 },
951 },
952 },
953 );
954 });
955});
packages/provider-cursor/src/server/adapter.ts created+2680
...@@ -0,0 +1,2680 @@
1import type {
2 AgentMessage,
3 AgentOptions,
4 InteractionUpdate,
5 McpServerConfig,
6 RunResult,
7 SDKUserMessage,
8 SettingSource,
9 ToolCall,
10} from "@cursor/sdk";
11import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
12import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
13import {
14 isOrchestrationV2WorkActive,
15 defaultInstanceIdForDriver,
16 type ChatAttachment,
17 type ModelSelection,
18 type OrchestrationV2ConversationMessage,
19 type OrchestrationV2ExecutionNode,
20 type OrchestrationV2PlanArtifact,
21 type OrchestrationV2PlanStep,
22 type OrchestrationV2ProviderCapabilities,
23 type OrchestrationV2ProviderFailure,
24 type OrchestrationV2ProviderSession,
25 type OrchestrationV2ProviderThread,
26 type OrchestrationV2ProviderTurn,
27 type OrchestrationV2Subagent,
28 type OrchestrationV2TurnItem,
29 type ProviderInstanceId,
30 type ThreadId,
31} from "@t3tools/contracts";
32import { CursorSettings } from "../settings.ts";
33import * as DateTime from "effect/DateTime";
34import * as Deferred from "effect/Deferred";
35import * as Effect from "effect/Effect";
36import * as FileSystem from "effect/FileSystem";
37import * as Layer from "effect/Layer";
38import * as Option from "effect/Option";
39import * as Path from "effect/Path";
40import * as Queue from "effect/Queue";
41import * as Ref from "effect/Ref";
42import * as Schema from "effect/Schema";
43import * as Stream from "effect/Stream";
44
45import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
46import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation";
47import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
48import { CursorTransportFailure } from "./transportFailure.ts";
49import { cursorSdkModelSelection } from "./sdkModel.ts";
50import {
51 discoverCursorSkills,
52 hasCursorSkillMention,
53 rewriteCursorSkillMentions,
54} from "./skills.ts";
55import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
56import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions";
57import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
58import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
59import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
60import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
61import {
62 isProviderNativeImageAttachment,
63 providerMessageTextWithAttachmentPaths,
64} from "@t3tools/provider-core/server/attachmentPrompt";
65import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
66import {
67 ProviderAdapterDriverCreateError,
68 type ProviderAdapterDriver,
69 type ProviderAdapterDriverCreateInput,
70} from "@t3tools/provider-core/server/adapterDriver";
71import {
72 makeSubagentChildThread,
73 makeSubagentConversationArtifacts,
74 subagentThreadTitle,
75} from "@t3tools/provider-core/server/subagentProjection";
76import * as CursorAgentSdk from "./CursorAgentSdk.ts";
77export { cursorSdkModelSelection } from "./sdkModel.ts";
78
79export const CURSOR_DRIVER_KIND = CursorAgentSdk.CURSOR_PROVIDER;
80export const CURSOR_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CURSOR_DRIVER_KIND);
81const DEFAULT_CURSOR_SETTINGS = Schema.decodeSync(CursorSettings)({});
82
83export const CursorProviderCapabilitiesV2 = {
84 sessions: {
85 supportsMultipleProviderThreadsPerSession: false,
86 supportsModelSwitchInSession: true,
87 supportsProviderSwitchingViaHandoff: true,
88 supportsRuntimeModeSwitchInSession: false,
89 pendingRequestsSurviveRestart: false,
90 },
91 threads: {
92 canCreateEmptyThread: true,
93 canReadThreadSnapshot: true,
94 canRollbackThread: false,
95 canForkThread: false,
96 canForkFromTurn: false,
97 canForkFromSubagentThread: false,
98 exposesNativeThreadId: true,
99 },
100 turns: {
101 exposesNativeTurnId: true,
102 emitsTurnStarted: true,
103 emitsTurnCompleted: true,
104 supportsInterrupt: true,
105 supportsActiveSteering: false,
106 supportsSteeringByInterruptRestart: true,
107 supportsQueuedMessages: true,
108 terminalStatusQuality: "strong",
109 },
110 streaming: {
111 streamsAssistantText: true,
112 streamsReasoning: true,
113 streamsToolOutput: true,
114 streamsPlanText: true,
115 emitsMessageCompleted: true,
116 },
117 tools: {
118 exposesToolItemIds: true,
119 emitsToolStarted: true,
120 emitsToolCompleted: true,
121 emitsToolOutput: true,
122 supportsMcpTools: true,
123 supportsDynamicToolCallbacks: false,
124 },
125 approvals: {
126 supportsCommandApproval: false,
127 supportsFileReadApproval: false,
128 supportsFileChangeApproval: false,
129 supportsApplyPatchApproval: false,
130 approvalsHaveNativeRequestIds: false,
131 approvalCallbacksAreLiveOnly: false,
132 approvalsCanOriginateFromSubagents: false,
133 },
134 planning: {
135 emitsPlanUpdated: true,
136 emitsTodoList: true,
137 emitsProposedPlan: true,
138 supportsStructuredQuestions: false,
139 planDeltasHaveItemIds: true,
140 },
141 subagents: {
142 supportsSubagents: true,
143 exposesSubagentThreadIds: false,
144 emitsSubagentLifecycle: true,
145 canWaitForSubagents: true,
146 canCloseSubagents: false,
147 canForkSubagentThread: false,
148 },
149 context: {
150 acceptsSystemContext: false,
151 acceptsDeveloperContext: false,
152 acceptsSyntheticUserContext: true,
153 canGenerateSummaries: true,
154 canConsumeHandoffSummaries: true,
155 supportsDeltaHandoff: true,
156 supportsFullThreadHandoff: true,
157 maxRecommendedHandoffChars: null,
158 },
159 checkpointing: {
160 appCanCheckpointFilesystem: true,
161 supportsNestedCheckpointScopes: true,
162 providerCanRollbackConversation: false,
163 providerRollbackReturnsSnapshot: false,
164 providerCanReadConversationSnapshot: true,
165 },
166 identity: {
167 nativeThreadIds: "strong",
168 nativeTurnIds: "strong",
169 nativeItemIds: "weak",
170 nativeRequestIds: "none",
171 },
172 runtimePolicy: {
173 enforcement: "native",
174 },
175} satisfies OrchestrationV2ProviderCapabilities;
176
177export interface CursorRuntimeAgentPolicy {
178 readonly autoReview: boolean;
179 readonly sandboxEnabled: boolean;
180}
181
182export function cursorRuntimeAgentPolicy(
183 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
184): CursorRuntimeAgentPolicy {
185 const sandboxPolicyType =
186 typeof runtimePolicy.sandboxPolicy === "object" &&
187 runtimePolicy.sandboxPolicy !== null &&
188 "type" in runtimePolicy.sandboxPolicy &&
189 typeof runtimePolicy.sandboxPolicy.type === "string"
190 ? runtimePolicy.sandboxPolicy.type
191 : undefined;
192 return {
193 autoReview:
194 runtimePolicy.approvalPolicy === undefined
195 ? runtimePolicy.runtimeMode === "approval-required"
196 : runtimePolicy.approvalPolicy !== "never",
197 sandboxEnabled:
198 runtimePolicy.sandboxPolicy === undefined
199 ? runtimePolicy.runtimeMode !== "full-access"
200 : sandboxPolicyType !== "dangerFullAccess",
201 };
202}
203
204export function cursorMcpServers(threadId: ThreadId): Record<string, McpServerConfig> | undefined {
205 const session = McpProviderSession.readMcpProviderSession(threadId);
206 if (session === undefined) {
207 return undefined;
208 }
209 return {
210 "t3-code": {
211 type: "http",
212 url: session.endpoint,
213 headers: {
214 Authorization: session.authorizationHeader,
215 },
216 },
217 };
218}
219
220function providerSession(input: {
221 readonly providerSessionId: OrchestrationV2ProviderSession["id"];
222 readonly providerInstanceId: ProviderInstanceId;
223 readonly cwd: string | null;
224 readonly model: string;
225 readonly now: DateTime.Utc;
226}): OrchestrationV2ProviderSession {
227 return {
228 id: input.providerSessionId,
229 driver: CursorAgentSdk.CURSOR_PROVIDER,
230 providerInstanceId: input.providerInstanceId,
231 status: "ready",
232 cwd: input.cwd ?? process.cwd(),
233 model: input.model,
234 capabilities: CursorProviderCapabilitiesV2,
235 createdAt: input.now,
236 updatedAt: input.now,
237 lastError: null,
238 };
239}
240
241function makeProviderThread(input: {
242 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
243 readonly providerInstanceId: ProviderInstanceId;
244 readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
245 readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
246 readonly nativeThreadId: string;
247 readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
248 readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"];
249 readonly now: DateTime.Utc;
250}): OrchestrationV2ProviderThread {
251 return {
252 id: input.idAllocator.derive.providerThread({
253 driver: CursorAgentSdk.CURSOR_PROVIDER,
254 nativeThreadId: input.nativeThreadId,
255 }),
256 driver: CursorAgentSdk.CURSOR_PROVIDER,
257 providerInstanceId: input.providerInstanceId,
258 providerSessionId: input.providerSessionId,
259 appThreadId: input.appThreadId,
260 ownerNodeId: input.ownerNodeId ?? null,
261 nativeThreadRef: {
262 driver: CursorAgentSdk.CURSOR_PROVIDER,
263 nativeId: input.nativeThreadId,
264 strength: "strong",
265 },
266 nativeConversationHeadRef: null,
267 status: "idle",
268 firstRunOrdinal: null,
269 lastRunOrdinal: null,
270 handoffIds: [],
271 forkedFrom: input.forkedFrom ?? null,
272 createdAt: input.now,
273 updatedAt: input.now,
274 };
275}
276
277function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string {
278 const id = providerThread.nativeThreadRef?.nativeId;
279 if (id === null || id === undefined) {
280 throw new ProviderAdapter.ProviderAdapterProtocolError({
281 driver: CursorAgentSdk.CURSOR_PROVIDER,
282 detail: `Provider thread ${providerThread.id} is missing its Cursor agent id.`,
283 });
284 }
285 return id;
286}
287
288/**
289 * Every Cursor settings layer the Cursor CLI loads: project and user rules,
290 * skills, hooks, and MCP servers, team and MDM admin policy, and account
291 * plugins. The SDK loads none of them when `settingSources` is omitted.
292 * Sandbox policy files are read either way, and hooks can only deny or ask
293 * (which local SDK runs reject), so these layers do not loosen the sandbox or
294 * approval mode T3 sets.
295 */
296const CURSOR_AGENT_SETTING_SOURCES = [
297 "project",
298 "user",
299 "team",
300 "mdm",
301 "plugins",
302] as const satisfies ReadonlyArray<SettingSource>;
303
304export function makeCursorAgentOptions(input: {
305 readonly apiKey?: string;
306 readonly modelSelection: ModelSelection;
307 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
308 readonly threadId: ThreadId;
309}): AgentOptions {
310 const policy = cursorRuntimeAgentPolicy(input.runtimePolicy);
311 const mcpServers = cursorMcpServers(input.threadId);
312 return {
313 model: cursorSdkModelSelection(input.modelSelection),
314 name: `T3 Code ${input.threadId}`,
315 mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "agent",
316 ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }),
317 local: {
318 ...(input.runtimePolicy.cwd === null ? {} : { cwd: input.runtimePolicy.cwd }),
319 autoReview: policy.autoReview,
320 settingSources: [...CURSOR_AGENT_SETTING_SOURCES],
321 sandboxOptions: {
322 enabled: policy.sandboxEnabled,
323 },
324 enableAgentRetries: true,
325 },
326 ...(mcpServers === undefined ? {} : { mcpServers }),
327 };
328}
329
330function stableJson(value: unknown): string {
331 try {
332 return JSON.stringify(value, null, 2);
333 } catch {
334 return String(value);
335 }
336}
337
338function unknownRecord(value: unknown): Record<string, unknown> | undefined {
339 return typeof value === "object" && value !== null && !Array.isArray(value)
340 ? (value as Record<string, unknown>)
341 : undefined;
342}
343
344function nestedString(value: unknown, keys: ReadonlyArray<string>): string | undefined {
345 let current: unknown = value;
346 for (const key of keys) {
347 current = unknownRecord(current)?.[key];
348 }
349 return typeof current === "string" ? current : undefined;
350}
351
352function cursorToolFailed(toolCall: ToolCall): boolean {
353 if (toolCall.result?.status === "error") {
354 return true;
355 }
356 return toolCall.type === "mcp" && toolCall.result?.status === "success"
357 ? toolCall.result.value.isError
358 : false;
359}
360
361function cursorToolOutput(toolCall: ToolCall): unknown {
362 const result = toolCall.result;
363 if (result === undefined) {
364 return undefined;
365 }
366 return result.status === "success" ? result.value : result.error;
367}
368
369function cursorToolOutputText(toolCall: ToolCall): string {
370 if (toolCall.type === "shell" && toolCall.result?.status === "success") {
371 return [toolCall.result.value.stdout, toolCall.result.value.stderr]
372 .filter((part) => part.length > 0)
373 .join("\n");
374 }
375 if (toolCall.type === "mcp" && toolCall.result?.status === "success") {
376 return toolCall.result.value.content
377 .flatMap((part) => (part.text === undefined ? [] : [part.text.text]))
378 .join("\n");
379 }
380 const output = cursorToolOutput(toolCall);
381 if (output === undefined) {
382 return "";
383 }
384 return typeof output === "string" ? output : stableJson(output);
385}
386
387function cursorToolName(toolCall: ToolCall): string {
388 if (toolCall.type !== "mcp") {
389 return toolCall.type;
390 }
391 const provider = toolCall.args.providerIdentifier ?? "mcp";
392 const tool = toolCall.args.toolName ?? "unknown";
393 return `mcp__${provider}__${tool}`;
394}
395
396function cursorToolFileName(toolCall: ToolCall): string {
397 switch (toolCall.type) {
398 case "write":
399 case "delete":
400 case "read":
401 case "edit":
402 case "ls":
403 return toolCall.args.path;
404 case "generateImage":
405 return toolCall.args.filePath ?? "generated-image";
406 default:
407 return cursorToolName(toolCall);
408 }
409}
410
411function cursorToolSearchPattern(toolCall: ToolCall): string | undefined {
412 switch (toolCall.type) {
413 case "glob":
414 return toolCall.args.globPattern;
415 case "grep":
416 return toolCall.args.pattern;
417 case "semSearch":
418 return toolCall.args.query;
419 case "ls":
420 return toolCall.args.path;
421 case "readLints":
422 return toolCall.args.paths.join(", ");
423 default:
424 return undefined;
425 }
426}
427
428type CursorLsDirectoryNode = Extract<
429 Extract<ToolCall, { readonly type: "ls" }>["result"],
430 { readonly status: "success" }
431>["value"]["directoryTreeRoot"];
432
433function cursorLsSearchResults(
434 node: CursorLsDirectoryNode,
435 path: Path.Path,
436): ReadonlyArray<{ readonly fileName: string }> {
437 return [
438 ...node.childrenFiles.map((file) => ({ fileName: path.join(node.absPath, file.name) })),
439 ...node.childrenDirs.flatMap((child) => cursorLsSearchResults(child, path)),
440 ];
441}
442
443function cursorToolSearchResults(
444 toolCall: ToolCall,
445 path: Path.Path,
446): ReadonlyArray<{
447 readonly fileName: string;
448 readonly line?: number;
449 readonly column?: number;
450 readonly preview?: string;
451}> {
452 if (toolCall.result?.status !== "success") {
453 return [];
454 }
455 switch (toolCall.type) {
456 case "glob":
457 return toolCall.result.value.files.map((fileName) => ({ fileName }));
458 case "grep":
459 return Object.values(toolCall.result.value.workspaceResults ?? {}).flatMap((result) => {
460 if (result.type === "files") {
461 return result.output.files.map((fileName) => ({ fileName }));
462 }
463 if (result.type === "count") {
464 return result.output.counts.map((entry) => ({
465 fileName: entry.file,
466 preview: `${entry.count} matches`,
467 }));
468 }
469 return result.output.matches.map((entry) => ({
470 fileName: entry.file,
471 ...(entry.lineNumber === undefined ? {} : { line: entry.lineNumber }),
472 preview: entry.line,
473 }));
474 });
475 case "ls":
476 return cursorLsSearchResults(toolCall.result.value.directoryTreeRoot, path);
477 case "readLints":
478 return toolCall.result.value.fileDiagnostics.flatMap((file) =>
479 file.diagnostics.map((diagnostic) => {
480 const line = diagnostic.range?.start?.line;
481 const character = diagnostic.range?.start?.character;
482 const hasLine = typeof line === "number" && Number.isInteger(line) && line >= 0;
483 return {
484 fileName: file.path,
485 ...(hasLine ? { line: line + 1 } : {}),
486 ...(hasLine &&
487 typeof character === "number" &&
488 Number.isInteger(character) &&
489 character >= 0
490 ? { column: character + 1 }
491 : {}),
492 preview: diagnostic.message,
493 };
494 }),
495 );
496 case "semSearch":
497 return [
498 {
499 fileName: "semantic-search",
500 preview: toolCall.result.value.results,
501 },
502 ];
503 default:
504 return [];
505 }
506}
507
508function cursorTodoSteps(
509 toolCall: Extract<ToolCall, { readonly type: "updateTodos" }>,
510): ReadonlyArray<OrchestrationV2PlanStep> {
511 const todos =
512 toolCall.result?.status === "success" ? toolCall.result.value.todos : toolCall.args.todos;
513 return todos
514 .filter((todo) => todo.status !== "cancelled" && todo.content.trim().length > 0)
515 .map((todo, index) => ({
516 id: `todo-${index + 1}`,
517 text: todo.content,
518 status:
519 todo.status === "inProgress"
520 ? "running"
521 : todo.status === "completed"
522 ? "completed"
523 : "pending",
524 }));
525}
526
527function assistantTextsFromConversationSteps(steps: ReadonlyArray<unknown>): ReadonlyArray<string> {
528 return steps.flatMap((step) => {
529 const record = unknownRecord(step);
530 if (record?.type === "assistantMessage") {
531 const text = nestedString(record, ["message", "text"]);
532 return text === undefined || text.length === 0 ? [] : [text];
533 }
534 const text = nestedString(record, ["assistantMessage", "text"]);
535 return text === undefined || text.length === 0 ? [] : [text];
536 });
537}
538
539function nestedToolResult<Value = Record<string, unknown>>(
540 rawResult: unknown,
541 mapSuccess: (success: Record<string, unknown>) => Value = (success) => success as Value,
542):
543 | { readonly status: "success"; readonly value: Value }
544 | { readonly status: "error"; readonly error: unknown }
545 | undefined {
546 const result = unknownRecord(rawResult);
547 if (result === undefined) {
548 return undefined;
549 }
550 const success = unknownRecord(result.success);
551 if (success !== undefined) {
552 return {
553 status: "success",
554 value: mapSuccess(success),
555 };
556 }
557 return {
558 status: "error",
559 error: result.error ?? result.permissionDenied ?? result,
560 };
561}
562
563function nestedGrepWorkspaceResults(success: Record<string, unknown>): Record<string, unknown> {
564 const workspaces = unknownRecord(success.workspaceResults);
565 if (workspaces === undefined) {
566 return {};
567 }
568 return Object.fromEntries(
569 Object.entries(workspaces).map(([workspace, rawWorkspaceResult]) => {
570 const workspaceResult = unknownRecord(rawWorkspaceResult);
571 const content = unknownRecord(workspaceResult?.content);
572 const rawMatches = Array.isArray(content?.matches) ? content.matches : [];
573 const matches = rawMatches.flatMap((rawFileMatch) => {
574 const fileMatch = unknownRecord(rawFileMatch);
575 const file = typeof fileMatch?.file === "string" ? fileMatch.file : "";
576 const lineMatches = Array.isArray(fileMatch?.matches) ? fileMatch.matches : [];
577 return lineMatches.flatMap((rawLineMatch) => {
578 const lineMatch = unknownRecord(rawLineMatch);
579 if (lineMatch === undefined) {
580 return [];
581 }
582 const line = typeof lineMatch.content === "string" ? lineMatch.content : undefined;
583 if (file.length === 0 || line === undefined) {
584 return [];
585 }
586 return [
587 {
588 file,
589 line,
590 ...(typeof lineMatch.lineNumber === "number"
591 ? { lineNumber: lineMatch.lineNumber }
592 : {}),
593 },
594 ];
595 });
596 });
597 return [
598 workspace,
599 {
600 type: "content",
601 output: {
602 matches,
603 totalMatches:
604 typeof content?.totalMatchedLines === "number"
605 ? content.totalMatchedLines
606 : matches.length,
607 },
608 },
609 ];
610 }),
611 );
612}
613
614export function nestedToolCallFromEnvelope(
615 envelope: Record<string, unknown>,
616): { readonly callId: string; readonly toolCall: ToolCall } | undefined {
617 const callId = typeof envelope.toolCallId === "string" ? envelope.toolCallId : undefined;
618 const wrapperEntry = Object.entries(envelope).find(([key]) => key.endsWith("ToolCall"));
619 if (wrapperEntry === undefined) {
620 return undefined;
621 }
622 const [wrapperName, rawCall] = wrapperEntry;
623 const call = unknownRecord(rawCall);
624 if (call === undefined) {
625 return undefined;
626 }
627 const args = unknownRecord(call.args) ?? {};
628 const fallbackCallId = `nested-${wrapperName}`;
629 const nestedCallId = callId ?? fallbackCallId;
630
631 switch (wrapperName) {
632 case "readToolCall": {
633 const success = unknownRecord(unknownRecord(call.result)?.success);
634 const path =
635 typeof args.path === "string"
636 ? args.path
637 : typeof success?.path === "string"
638 ? success.path
639 : "<unknown path>";
640 return {
641 callId: nestedCallId,
642 toolCall: {
643 type: "read",
644 args: { path },
645 result: nestedToolResult(call.result, (value) => ({
646 content: typeof value.content === "string" ? value.content : "",
647 totalLines: typeof value.totalLines === "number" ? value.totalLines : 0,
648 fileSize: typeof value.fileSize === "number" ? value.fileSize : 0,
649 })),
650 } as unknown as ToolCall,
651 };
652 }
653 case "globToolCall":
654 return {
655 callId: nestedCallId,
656 toolCall: {
657 type: "glob",
658 args: {
659 globPattern: typeof args.globPattern === "string" ? args.globPattern : "**/*",
660 ...(typeof args.targetDirectory === "string"
661 ? { targetDirectory: args.targetDirectory }
662 : {}),
663 },
664 result: nestedToolResult(call.result, (value) => ({
665 files: Array.isArray(value.files)
666 ? value.files.filter((file): file is string => typeof file === "string")
667 : [],
668 totalFiles: typeof value.totalFiles === "number" ? value.totalFiles : 0,
669 clientTruncated: false,
670 ripgrepTruncated: false,
671 })),
672 } as unknown as ToolCall,
673 };
674 case "grepToolCall":
675 return {
676 callId: nestedCallId,
677 toolCall: {
678 type: "grep",
679 args: {
680 pattern: typeof args.pattern === "string" ? args.pattern : "",
681 ...(typeof args.path === "string" ? { path: args.path } : {}),
682 ...(typeof args.glob === "string" ? { glob: args.glob } : {}),
683 ...(typeof args.outputMode === "string" ? { outputMode: args.outputMode } : {}),
684 ...(typeof args.caseInsensitive === "boolean"
685 ? { caseInsensitive: args.caseInsensitive }
686 : {}),
687 ...(typeof args.offset === "number" ? { offset: args.offset } : {}),
688 ...(typeof args.multiline === "boolean" ? { multiline: args.multiline } : {}),
689 },
690 result: nestedToolResult(call.result, (value) => ({
691 workspaceResults: nestedGrepWorkspaceResults(value),
692 })),
693 } as unknown as ToolCall,
694 };
695 case "shellToolCall": {
696 const rawResult = unknownRecord(call.result);
697 const permissionDenied = unknownRecord(rawResult?.permissionDenied);
698 const command =
699 typeof args.command === "string"
700 ? args.command
701 : typeof permissionDenied?.command === "string"
702 ? permissionDenied.command
703 : "<unknown command>";
704 return {
705 callId: nestedCallId,
706 toolCall: {
707 type: "shell",
708 args: { command },
709 result: nestedToolResult(call.result, (value) => ({
710 stdout: typeof value.stdout === "string" ? value.stdout : "",
711 stderr: typeof value.stderr === "string" ? value.stderr : "",
712 exitCode: typeof value.exitCode === "number" ? value.exitCode : 0,
713 signal: typeof value.signal === "string" ? value.signal : "",
714 executionTime: typeof value.executionTime === "number" ? value.executionTime : 0,
715 })),
716 } as unknown as ToolCall,
717 };
718 }
719 default: {
720 const type = wrapperName.slice(0, -"ToolCall".length);
721 return {
722 callId: nestedCallId,
723 toolCall: {
724 type,
725 args,
726 result: nestedToolResult(call.result),
727 } as unknown as ToolCall,
728 };
729 }
730 }
731}
732
733function toolCallsFromConversationSteps(
734 steps: ReadonlyArray<unknown>,
735): ReadonlyArray<{ readonly callId: string; readonly toolCall: ToolCall }> {
736 return steps.flatMap((step) => {
737 const record = unknownRecord(step);
738 if (record?.type === "toolCall") {
739 const message = unknownRecord(record.message);
740 return typeof message?.type === "string"
741 ? [
742 {
743 callId: typeof record.callId === "string" ? record.callId : `nested-${message.type}`,
744 toolCall: message as ToolCall,
745 },
746 ]
747 : [];
748 }
749 const envelope = unknownRecord(record?.toolCall);
750 const nested = envelope === undefined ? undefined : nestedToolCallFromEnvelope(envelope);
751 return nested === undefined ? [] : [nested];
752 });
753}
754
755function textFromAgentMessage(message: AgentMessage): string {
756 const value = message.message;
757 if (typeof value === "string") {
758 return value;
759 }
760 const direct =
761 nestedString(value, ["text"]) ??
762 nestedString(value, ["message", "text"]) ??
763 nestedString(value, ["userMessage", "text"]) ??
764 nestedString(value, ["assistantMessage", "text"]);
765 if (direct !== undefined) {
766 return direct;
767 }
768 const content = unknownRecord(value)?.content;
769 if (!Array.isArray(content)) {
770 return "";
771 }
772 return content
773 .flatMap((part) => {
774 const text = unknownRecord(part)?.text;
775 return typeof text === "string" ? [text] : [];
776 })
777 .join("");
778}
779
780interface CursorProjectionTarget {
781 readonly threadId: ThreadId;
782 readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null;
783 readonly rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"];
784 readonly parentNodeId: OrchestrationV2ExecutionNode["id"];
785 readonly providerThreadId: OrchestrationV2ProviderThread["id"] | null;
786 readonly providerTurnId: OrchestrationV2ProviderTurn["id"] | null;
787}
788
789interface ActiveCursorToolCall {
790 readonly callId: string;
791 toolCall: ToolCall;
792 readonly target: CursorProjectionTarget;
793 readonly ordinal: number;
794 readonly startedAt: DateTime.Utc;
795 streamedOutput: string;
796}
797
798interface ActiveCursorSubagent {
799 task: OrchestrationV2Subagent;
800 toolCall: Extract<ToolCall, { readonly type: "task" }>;
801 readonly callId: string;
802 readonly childThreadId: ThreadId;
803 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
804 readonly turnItemId: OrchestrationV2TurnItem["id"];
805 readonly turnItemOrdinal: number;
806 nextChildOrdinal: number;
807 resultProjected: boolean;
808}
809
810interface ActiveCursorTextSegment {
811 readonly nativeItemId: string;
812 readonly startedAt: DateTime.Utc;
813 text: string;
814}
815
816interface ActiveCursorTextStream {
817 current: ActiveCursorTextSegment | null;
818 nextSegment: number;
819}
820
821interface ActiveCursorTurn {
822 readonly input: ProviderAdapter.ProviderAdapterV2TurnInput;
823 readonly run: CursorAgentSdk.CursorAgentSdkRun;
824 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
825 readonly startedAt: DateTime.Utc;
826 readonly completed: Deferred.Deferred<void, never>;
827 // Item ordinals allocated in this turn. No later turn looks items up here.
828 readonly itemOrdinals: Map<string, number>;
829 readonly tools: Map<string, ActiveCursorToolCall>;
830 readonly subagents: Map<string, ActiveCursorSubagent>;
831 readonly assistant: ActiveCursorTextStream;
832 readonly assistantReply: CursorTransportFailure;
833 readonly reasoning: ActiveCursorTextStream;
834 interrupted: boolean;
835 finalized: boolean;
836}
837
838interface CursorLiveAgent {
839 readonly nativeThreadId: string;
840 readonly session: CursorAgentSdk.CursorAgentSdkSession;
841}
842
843export interface CursorAdapterV2Options {
844 readonly instanceId: ProviderInstanceId;
845 readonly settings: CursorSettings;
846 readonly environment: NodeJS.ProcessEnv;
847 readonly fileSystem: FileSystem.FileSystem;
848 readonly path: Path.Path;
849 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
850 readonly runner: CursorAgentSdk.CursorAgentSdkRunnerShape;
851 readonly host: ProviderHost.ProviderHostShape;
852}
853
854export function makeCursorAdapterV2(
855 adapterOptions: CursorAdapterV2Options,
856): ProviderAdapter.ProviderAdapterV2Shape {
857 const { fileSystem, path, idAllocator, runner, host } = adapterOptions;
858 const apiKey = adapterOptions.environment.CURSOR_API_KEY?.trim() || undefined;
859
860 return ProviderAdapter.ProviderAdapterV2.of({
861 instanceId: adapterOptions.instanceId,
862 driver: CursorAgentSdk.CURSOR_PROVIDER,
863 getCapabilities: () => Effect.succeed(CursorProviderCapabilitiesV2),
864 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
865 openSession: Effect.fn("CursorAdapterV2.openSession")(
866 function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) {
867 const sessionScope = yield* Effect.scope;
868 const createdAt = yield* DateTime.now;
869 const session = providerSession({
870 providerSessionId: input.providerSessionId,
871 providerInstanceId: adapterOptions.instanceId,
872 cwd: input.runtimePolicy.cwd,
873 model: input.modelSelection.model,
874 now: createdAt,
875 });
876 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
877 const liveAgent = yield* Ref.make<CursorLiveAgent | null>(null);
878 const activeTurn = yield* Ref.make<ActiveCursorTurn | null>(null);
879 const planIds = yield* Ref.make(new Map<string, OrchestrationV2PlanArtifact["id"]>());
880
881 const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) =>
882 Queue.offer(events, event).pipe(Effect.asVoid);
883
884 const resolveItemOrdinal = (context: ActiveCursorTurn, nativeItemId: string) =>
885 Effect.sync(() => {
886 const existing = context.itemOrdinals.get(nativeItemId);
887 if (existing !== undefined) {
888 return existing;
889 }
890 const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1;
891 context.itemOrdinals.set(nativeItemId, ordinal);
892 return ordinal;
893 });
894
895 const resolvePlanId = Effect.fnUntraced(function* (
896 context: ActiveCursorTurn,
897 nativeItemId: string,
898 ) {
899 const existing = (yield* Ref.get(planIds)).get(nativeItemId);
900 if (existing !== undefined) {
901 return existing;
902 }
903 const planId = yield* idAllocator.allocate.plan({
904 threadId: context.input.threadId,
905 runId: context.input.runId,
906 driver: CursorAgentSdk.CURSOR_PROVIDER,
907 });
908 yield* Ref.update(planIds, (current) => {
909 const updated = new Map(current);
910 updated.set(nativeItemId, planId);
911 return updated;
912 });
913 return planId;
914 });
915
916 const parentTarget = (context: ActiveCursorTurn): CursorProjectionTarget => ({
917 threadId: context.input.threadId,
918 runId: context.input.runId,
919 rootNodeId: context.input.rootNodeId,
920 parentNodeId: context.input.rootNodeId,
921 providerThreadId: context.input.providerThread.id,
922 providerTurnId: context.providerTurnId,
923 });
924
925 const emitAssistant = Effect.fnUntraced(function* (
926 context: ActiveCursorTurn,
927 completed: boolean,
928 ) {
929 const segment = context.assistant.current;
930 if (segment === null || segment.text.length === 0) {
931 return;
932 }
933 const now = yield* DateTime.now;
934 const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId);
935 const nodeId = idAllocator.derive.nodeFromProviderItem({
936 driver: CursorAgentSdk.CURSOR_PROVIDER,
937 nativeItemId: segment.nativeItemId,
938 });
939 const messageId = idAllocator.derive.messageFromProviderItem({
940 driver: CursorAgentSdk.CURSOR_PROVIDER,
941 nativeItemId: segment.nativeItemId,
942 });
943 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
944 driver: CursorAgentSdk.CURSOR_PROVIDER,
945 nativeItemId: segment.nativeItemId,
946 });
947 const nativeItemRef = {
948 driver: CursorAgentSdk.CURSOR_PROVIDER,
949 nativeId: segment.nativeItemId,
950 strength: "weak" as const,
951 };
952 yield* emitProviderEvent({
953 type: "node.updated",
954 driver: CursorAgentSdk.CURSOR_PROVIDER,
955 node: {
956 id: nodeId,
957 threadId: context.input.threadId,
958 runId: context.input.runId,
959 parentNodeId: context.input.rootNodeId,
960 rootNodeId: context.input.rootNodeId,
961 kind: "assistant_message",
962 status: completed ? "completed" : "running",
963 countsForRun: false,
964 providerThreadId: context.input.providerThread.id,
965 providerTurnId: context.providerTurnId,
966 nativeItemRef,
967 runtimeRequestId: null,
968 checkpointScopeId: null,
969 startedAt: segment.startedAt,
970 completedAt: completed ? now : null,
971 },
972 });
973 yield* emitProviderEvent({
974 type: "message.updated",
975 driver: CursorAgentSdk.CURSOR_PROVIDER,
976 message: {
977 createdBy: "agent",
978 creationSource: "provider",
979 id: messageId,
980 threadId: context.input.threadId,
981 runId: context.input.runId,
982 nodeId,
983 role: "assistant",
984 text: segment.text,
985 attachments: [],
986 streaming: !completed,
987 createdAt: segment.startedAt,
988 updatedAt: now,
989 },
990 });
991 yield* emitProviderEvent({
992 type: "turn_item.updated",
993 driver: CursorAgentSdk.CURSOR_PROVIDER,
994 turnItem: {
995 id: turnItemId,
996 threadId: context.input.threadId,
997 runId: context.input.runId,
998 nodeId,
999 providerThreadId: context.input.providerThread.id,
1000 providerTurnId: context.providerTurnId,
1001 nativeItemRef,
1002 parentItemId: null,
1003 ordinal,
1004 status: completed ? "completed" : "running",
1005 title: null,
1006 startedAt: segment.startedAt,
1007 completedAt: completed ? now : null,
1008 updatedAt: now,
1009 type: "assistant_message",
1010 messageId,
1011 text: segment.text,
1012 streaming: !completed,
1013 },
1014 });
1015 });
1016
1017 const emitReasoning = Effect.fnUntraced(function* (
1018 context: ActiveCursorTurn,
1019 completed: boolean,
1020 ) {
1021 const segment = context.reasoning.current;
1022 if (segment === null || segment.text.length === 0) {
1023 return;
1024 }
1025 const now = yield* DateTime.now;
1026 const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId);
1027 const nodeId = idAllocator.derive.nodeFromProviderItem({
1028 driver: CursorAgentSdk.CURSOR_PROVIDER,
1029 nativeItemId: segment.nativeItemId,
1030 });
1031 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1032 driver: CursorAgentSdk.CURSOR_PROVIDER,
1033 nativeItemId: segment.nativeItemId,
1034 });
1035 const nativeItemRef = {
1036 driver: CursorAgentSdk.CURSOR_PROVIDER,
1037 nativeId: segment.nativeItemId,
1038 strength: "weak" as const,
1039 };
1040 yield* emitProviderEvent({
1041 type: "node.updated",
1042 driver: CursorAgentSdk.CURSOR_PROVIDER,
1043 node: {
1044 id: nodeId,
1045 threadId: context.input.threadId,
1046 runId: context.input.runId,
1047 parentNodeId: context.input.rootNodeId,
1048 rootNodeId: context.input.rootNodeId,
1049 kind: "reasoning",
1050 status: completed ? "completed" : "running",
1051 countsForRun: false,
1052 providerThreadId: context.input.providerThread.id,
1053 providerTurnId: context.providerTurnId,
1054 nativeItemRef,
1055 runtimeRequestId: null,
1056 checkpointScopeId: null,
1057 startedAt: segment.startedAt,
1058 completedAt: completed ? now : null,
1059 },
1060 });
1061 yield* emitProviderEvent({
1062 type: "turn_item.updated",
1063 driver: CursorAgentSdk.CURSOR_PROVIDER,
1064 turnItem: {
1065 id: turnItemId,
1066 threadId: context.input.threadId,
1067 runId: context.input.runId,
1068 nodeId,
1069 providerThreadId: context.input.providerThread.id,
1070 providerTurnId: context.providerTurnId,
1071 nativeItemRef,
1072 parentItemId: null,
1073 ordinal,
1074 status: completed ? "completed" : "running",
1075 title: null,
1076 startedAt: segment.startedAt,
1077 completedAt: completed ? now : null,
1078 updatedAt: now,
1079 type: "reasoning",
1080 text: segment.text,
1081 streaming: !completed,
1082 },
1083 });
1084 });
1085
1086 const appendTextSegment = Effect.fnUntraced(function* (input: {
1087 readonly context: ActiveCursorTurn;
1088 readonly stream: ActiveCursorTextStream;
1089 readonly kind: "assistant" | "reasoning";
1090 readonly text: string;
1091 }) {
1092 if (input.stream.current === null) {
1093 input.stream.nextSegment += 1;
1094 input.stream.current = {
1095 nativeItemId: `${input.kind}:${input.context.run.runId}:${input.stream.nextSegment}`,
1096 startedAt: yield* DateTime.now,
1097 text: "",
1098 };
1099 }
1100 input.stream.current.text += input.text;
1101 });
1102
1103 const completeAssistant = Effect.fnUntraced(function* (context: ActiveCursorTurn) {
1104 if (context.assistant.current === null) {
1105 return;
1106 }
1107 yield* emitAssistant(context, true);
1108 context.assistant.current = null;
1109 });
1110
1111 const completeReasoning = Effect.fnUntraced(function* (context: ActiveCursorTurn) {
1112 if (context.reasoning.current === null) {
1113 return;
1114 }
1115 yield* emitReasoning(context, true);
1116 context.reasoning.current = null;
1117 });
1118
1119 const emitToolArtifacts = Effect.fnUntraced(function* (input: {
1120 readonly active: ActiveCursorToolCall;
1121 readonly completed: boolean;
1122 /** Terminal status for a tool the turn ended before Cursor completed it. */
1123 readonly unfinishedStatus?: "interrupted" | "failed" | "cancelled";
1124 }) {
1125 const { active } = input;
1126 const toolCall = active.toolCall;
1127 const now = yield* DateTime.now;
1128 const failed = input.completed && cursorToolFailed(toolCall);
1129 const status = !input.completed
1130 ? "running"
1131 : (input.unfinishedStatus ?? (failed ? "failed" : "completed"));
1132 const nodeId = idAllocator.derive.nodeFromProviderItem({
1133 driver: CursorAgentSdk.CURSOR_PROVIDER,
1134 nativeItemId: active.callId,
1135 });
1136 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1137 driver: CursorAgentSdk.CURSOR_PROVIDER,
1138 nativeItemId: active.callId,
1139 });
1140 const nativeItemRef = {
1141 driver: CursorAgentSdk.CURSOR_PROVIDER,
1142 nativeId: active.callId,
1143 strength: "strong" as const,
1144 };
1145 const node: OrchestrationV2ExecutionNode = {
1146 id: nodeId,
1147 threadId: active.target.threadId,
1148 runId: active.target.runId,
1149 parentNodeId: active.target.parentNodeId,
1150 rootNodeId: active.target.rootNodeId,
1151 kind: "tool_call",
1152 status,
1153 countsForRun: false,
1154 providerThreadId: active.target.providerThreadId,
1155 providerTurnId: active.target.providerTurnId,
1156 nativeItemRef,
1157 runtimeRequestId: null,
1158 checkpointScopeId: null,
1159 startedAt: active.startedAt,
1160 completedAt: input.completed ? now : null,
1161 };
1162 const base = {
1163 id: turnItemId,
1164 threadId: active.target.threadId,
1165 runId: active.target.runId,
1166 nodeId,
1167 providerThreadId: active.target.providerThreadId,
1168 providerTurnId: active.target.providerTurnId,
1169 nativeItemRef,
1170 parentItemId: null,
1171 ordinal: active.ordinal,
1172 status,
1173 title: null,
1174 startedAt: active.startedAt,
1175 completedAt: input.completed ? now : null,
1176 updatedAt: now,
1177 } satisfies Pick<
1178 OrchestrationV2TurnItem,
1179 | "id"
1180 | "threadId"
1181 | "runId"
1182 | "nodeId"
1183 | "providerThreadId"
1184 | "providerTurnId"
1185 | "nativeItemRef"
1186 | "parentItemId"
1187 | "ordinal"
1188 | "status"
1189 | "title"
1190 | "startedAt"
1191 | "completedAt"
1192 | "updatedAt"
1193 >;
1194 const outputText = cursorToolOutputText(toolCall) || active.streamedOutput;
1195 let turnItem: OrchestrationV2TurnItem;
1196 switch (toolCall.type) {
1197 case "shell":
1198 turnItem = {
1199 ...base,
1200 type: "command_execution",
1201 input: toolCall.args.command,
1202 ...(outputText.length === 0 ? {} : { output: outputText }),
1203 ...(toolCall.result?.status === "success"
1204 ? { exitCode: toolCall.result.value.exitCode }
1205 : {}),
1206 };
1207 break;
1208 case "write":
1209 case "delete":
1210 case "edit":
1211 case "generateImage":
1212 turnItem = {
1213 ...base,
1214 type: "file_change",
1215 fileName: cursorToolFileName(toolCall),
1216 ...(toolCall.type === "edit" &&
1217 toolCall.result?.status === "success" &&
1218 toolCall.result.value.linesAdded !== undefined
1219 ? { additions: toolCall.result.value.linesAdded }
1220 : {}),
1221 ...(toolCall.type === "edit" &&
1222 toolCall.result?.status === "success" &&
1223 toolCall.result.value.linesRemoved !== undefined
1224 ? { deletions: toolCall.result.value.linesRemoved }
1225 : {}),
1226 ...(toolCall.type === "edit" &&
1227 toolCall.result?.status === "success" &&
1228 toolCall.result.value.diffString !== undefined
1229 ? { diffStr: toolCall.result.value.diffString }
1230 : // A failed change keeps its error where the diff would be.
1231 toolCall.result?.status === "error" && outputText.trim().length > 0
1232 ? { diffStr: outputText }
1233 : {}),
1234 ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}),
1235 };
1236 break;
1237 case "read":
1238 turnItem = {
1239 ...base,
1240 title: formatReadToolLabel(toolCall.args.path),
1241 type: "dynamic_tool",
1242 toolName: "Read",
1243 input: toolCall.args,
1244 ...(cursorToolOutput(toolCall) === undefined
1245 ? {}
1246 : { output: cursorToolOutput(toolCall) }),
1247 };
1248 break;
1249 case "glob":
1250 case "grep":
1251 case "ls":
1252 case "readLints":
1253 case "semSearch": {
1254 const pattern = cursorToolSearchPattern(toolCall);
1255 const searchPath =
1256 toolCall.type === "grep"
1257 ? toolCall.args.path
1258 : toolCall.type === "glob"
1259 ? toolCall.args.targetDirectory
1260 : toolCall.type === "semSearch"
1261 ? toolCall.args.targetDirectories?.join(", ")
1262 : pattern;
1263 // A failed search keeps its error as one row under the searched path.
1264 const results =
1265 toolCall.result?.status === "error" && outputText.trim().length > 0
1266 ? [{ fileName: searchPath?.trim() || ".", preview: outputText }]
1267 : cursorToolSearchResults(toolCall, path);
1268 turnItem = {
1269 ...base,
1270 title:
1271 formatSearchToolLabel({
1272 input: toolCall.args,
1273 ...(pattern === undefined ? {} : { pattern }),
1274 }) ?? null,
1275 type: "file_search",
1276 ...(pattern === undefined ? {} : { pattern }),
1277 ...(results.length === 0 ? {} : { results: [...results] }),
1278 };
1279 break;
1280 }
1281 default:
1282 turnItem = {
1283 ...base,
1284 type: "dynamic_tool",
1285 ...(toolCall.type === "mcp"
1286 ? mcpToolPresentation({
1287 serverName: toolCall.args.providerIdentifier,
1288 toolName: toolCall.args.toolName,
1289 })
1290 : {}),
1291 toolName: cursorToolName(toolCall),
1292 input: toolCall.args,
1293 ...(cursorToolOutput(toolCall) === undefined
1294 ? {}
1295 : { output: cursorToolOutput(toolCall) }),
1296 };
1297 }
1298 yield* emitProviderEvent({
1299 type: "node.updated",
1300 driver: CursorAgentSdk.CURSOR_PROVIDER,
1301 node,
1302 });
1303 yield* emitProviderEvent({
1304 type: "turn_item.updated",
1305 driver: CursorAgentSdk.CURSOR_PROVIDER,
1306 turnItem,
1307 });
1308 });
1309
1310 const ensureToolStarted = Effect.fnUntraced(function* (
1311 context: ActiveCursorTurn,
1312 callId: string,
1313 toolCall: ToolCall,
1314 ) {
1315 const existing = context.tools.get(callId);
1316 if (existing !== undefined) {
1317 existing.toolCall = toolCall;
1318 return existing;
1319 }
1320 const startedAt = yield* DateTime.now;
1321 const active: ActiveCursorToolCall = {
1322 callId,
1323 toolCall,
1324 target: parentTarget(context),
1325 ordinal: yield* resolveItemOrdinal(context, callId),
1326 startedAt,
1327 streamedOutput: "",
1328 };
1329 context.tools.set(callId, active);
1330 yield* emitToolArtifacts({ active, completed: false });
1331 return active;
1332 });
1333
1334 const emitPlanArtifacts = Effect.fnUntraced(function* (input: {
1335 readonly context: ActiveCursorTurn;
1336 readonly callId: string;
1337 readonly markdown: string;
1338 readonly completed: boolean;
1339 readonly failed: boolean;
1340 }) {
1341 const nativeItemId = `plan:${input.callId}`;
1342 const now = yield* DateTime.now;
1343 const planId = yield* resolvePlanId(input.context, nativeItemId);
1344 const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId);
1345 const nodeId = idAllocator.derive.nodeFromProviderItem({
1346 driver: CursorAgentSdk.CURSOR_PROVIDER,
1347 nativeItemId,
1348 });
1349 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1350 driver: CursorAgentSdk.CURSOR_PROVIDER,
1351 nativeItemId,
1352 });
1353 const status = input.failed ? "failed" : input.completed ? "completed" : "running";
1354 const nativeItemRef = {
1355 driver: CursorAgentSdk.CURSOR_PROVIDER,
1356 nativeId: input.callId,
1357 strength: "strong" as const,
1358 };
1359 const plan: OrchestrationV2PlanArtifact = {
1360 id: planId,
1361 threadId: input.context.input.threadId,
1362 runId: input.context.input.runId,
1363 nodeId,
1364 kind: "proposed_plan",
1365 status: input.completed ? "active" : "draft",
1366 markdown: input.markdown,
1367 };
1368 yield* emitProviderEvent({
1369 type: "node.updated",
1370 driver: CursorAgentSdk.CURSOR_PROVIDER,
1371 node: {
1372 id: nodeId,
1373 threadId: input.context.input.threadId,
1374 runId: input.context.input.runId,
1375 parentNodeId: input.context.input.rootNodeId,
1376 rootNodeId: input.context.input.rootNodeId,
1377 kind: "plan",
1378 status,
1379 countsForRun: false,
1380 providerThreadId: input.context.input.providerThread.id,
1381 providerTurnId: input.context.providerTurnId,
1382 nativeItemRef,
1383 runtimeRequestId: null,
1384 checkpointScopeId: null,
1385 startedAt: input.context.startedAt,
1386 completedAt: input.completed ? now : null,
1387 },
1388 });
1389 yield* emitProviderEvent({
1390 type: "plan.updated",
1391 driver: CursorAgentSdk.CURSOR_PROVIDER,
1392 plan,
1393 });
1394 yield* emitProviderEvent({
1395 type: "turn_item.updated",
1396 driver: CursorAgentSdk.CURSOR_PROVIDER,
1397 turnItem: {
1398 id: turnItemId,
1399 threadId: input.context.input.threadId,
1400 runId: input.context.input.runId,
1401 nodeId,
1402 providerThreadId: input.context.input.providerThread.id,
1403 providerTurnId: input.context.providerTurnId,
1404 nativeItemRef,
1405 parentItemId: null,
1406 ordinal,
1407 status,
1408 title: null,
1409 startedAt: input.context.startedAt,
1410 completedAt: input.completed ? now : null,
1411 updatedAt: now,
1412 type: "proposed_plan",
1413 planId,
1414 markdown: input.markdown,
1415 streaming: !input.completed,
1416 },
1417 });
1418 });
1419
1420 const emitTodoArtifacts = Effect.fnUntraced(function* (input: {
1421 readonly context: ActiveCursorTurn;
1422 readonly callId: string;
1423 readonly toolCall: Extract<ToolCall, { readonly type: "updateTodos" }>;
1424 readonly completed: boolean;
1425 readonly failed: boolean;
1426 }) {
1427 const nativeItemId = `todos:${input.callId}`;
1428 const now = yield* DateTime.now;
1429 const planId = yield* resolvePlanId(input.context, nativeItemId);
1430 const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId);
1431 const nodeId = idAllocator.derive.nodeFromProviderItem({
1432 driver: CursorAgentSdk.CURSOR_PROVIDER,
1433 nativeItemId,
1434 });
1435 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1436 driver: CursorAgentSdk.CURSOR_PROVIDER,
1437 nativeItemId,
1438 });
1439 const status = input.failed ? "failed" : input.completed ? "completed" : "running";
1440 const steps = cursorTodoSteps(input.toolCall);
1441 const nativeItemRef = {
1442 driver: CursorAgentSdk.CURSOR_PROVIDER,
1443 nativeId: input.callId,
1444 strength: "strong" as const,
1445 };
1446 yield* emitProviderEvent({
1447 type: "node.updated",
1448 driver: CursorAgentSdk.CURSOR_PROVIDER,
1449 node: {
1450 id: nodeId,
1451 threadId: input.context.input.threadId,
1452 runId: input.context.input.runId,
1453 parentNodeId: input.context.input.rootNodeId,
1454 rootNodeId: input.context.input.rootNodeId,
1455 kind: "todo_list",
1456 status,
1457 countsForRun: false,
1458 providerThreadId: input.context.input.providerThread.id,
1459 providerTurnId: input.context.providerTurnId,
1460 nativeItemRef,
1461 runtimeRequestId: null,
1462 checkpointScopeId: null,
1463 startedAt: input.context.startedAt,
1464 completedAt: input.completed ? now : null,
1465 },
1466 });
1467 yield* emitProviderEvent({
1468 type: "plan.updated",
1469 driver: CursorAgentSdk.CURSOR_PROVIDER,
1470 plan: {
1471 id: planId,
1472 threadId: input.context.input.threadId,
1473 runId: input.context.input.runId,
1474 nodeId,
1475 kind: "todo_list",
1476 status: input.completed ? "active" : "draft",
1477 steps: [...steps],
1478 },
1479 });
1480 yield* emitProviderEvent({
1481 type: "turn_item.updated",
1482 driver: CursorAgentSdk.CURSOR_PROVIDER,
1483 turnItem: {
1484 id: turnItemId,
1485 threadId: input.context.input.threadId,
1486 runId: input.context.input.runId,
1487 nodeId,
1488 providerThreadId: input.context.input.providerThread.id,
1489 providerTurnId: input.context.providerTurnId,
1490 nativeItemRef,
1491 parentItemId: null,
1492 ordinal,
1493 status,
1494 title: null,
1495 startedAt: input.context.startedAt,
1496 completedAt: input.completed ? now : null,
1497 updatedAt: now,
1498 type: "todo_list",
1499 planId,
1500 steps: [...steps],
1501 },
1502 });
1503 });
1504
1505 const emitSubagent = Effect.fnUntraced(function* (input: {
1506 readonly context: ActiveCursorTurn;
1507 readonly callId: string;
1508 readonly toolCall: Extract<ToolCall, { readonly type: "task" }>;
1509 readonly completed: boolean;
1510 readonly status?: OrchestrationV2Subagent["status"];
1511 }) {
1512 const args = input.toolCall.args;
1513 const result =
1514 input.toolCall.result?.status === "success" ? input.toolCall.result.value : undefined;
1515 const existing = input.context.subagents.get(input.callId);
1516 if (
1517 existing !== undefined &&
1518 !isOrchestrationV2WorkActive(existing.task.status) &&
1519 !input.completed
1520 )
1521 return;
1522 const now = yield* DateTime.now;
1523 const status: OrchestrationV2Subagent["status"] =
1524 input.status ??
1525 (input.completed
1526 ? cursorToolFailed(input.toolCall)
1527 ? "failed"
1528 : "completed"
1529 : "running");
1530 const resultText = [
1531 ...assistantTextsFromConversationSteps(result?.conversationSteps ?? []),
1532 ...(result?.resultSuffix === undefined ? [] : [result.resultSuffix]),
1533 ]
1534 .filter((part) => part.trim().length > 0)
1535 .join("\n");
1536 const nativeItemId = input.callId;
1537 const nodeId =
1538 existing?.task.id ??
1539 idAllocator.derive.nodeFromProviderItem({
1540 driver: CursorAgentSdk.CURSOR_PROVIDER,
1541 nativeItemId,
1542 });
1543 const childRootNodeId =
1544 existing?.childRootNodeId ??
1545 idAllocator.derive.nodeFromProviderItem({
1546 driver: CursorAgentSdk.CURSOR_PROVIDER,
1547 nativeItemId: `${nativeItemId}:child-root`,
1548 });
1549 const childThreadId =
1550 existing?.childThreadId ??
1551 idAllocator.derive.threadFromProviderThread({
1552 driver: CursorAgentSdk.CURSOR_PROVIDER,
1553 nativeThreadId: `${input.context.run.runId}:task:${input.callId}`,
1554 });
1555 const task: OrchestrationV2Subagent = {
1556 ...(existing?.task ?? {
1557 id: nodeId,
1558 threadId: input.context.input.threadId,
1559 runId: input.context.input.runId,
1560 parentNodeId: input.context.input.rootNodeId,
1561 origin: "provider_native" as const,
1562 createdBy: "agent" as const,
1563 driver: CursorAgentSdk.CURSOR_PROVIDER,
1564 providerInstanceId: input.context.input.modelSelection.instanceId,
1565 providerThreadId: null,
1566 childThreadId,
1567 nativeTaskRef: {
1568 driver: CursorAgentSdk.CURSOR_PROVIDER,
1569 nativeId: input.callId,
1570 strength: "strong" as const,
1571 },
1572 prompt: args.prompt,
1573 title: args.description,
1574 result: null,
1575 startedAt: now,
1576 }),
1577 model: args.model?.trim() || existing?.task.model || null,
1578 nativeTaskRef: {
1579 driver: CursorAgentSdk.CURSOR_PROVIDER,
1580 nativeId: input.callId,
1581 strength: "strong" as const,
1582 },
1583 status,
1584 result: resultText.length === 0 ? (existing?.task.result ?? null) : resultText,
1585 completedAt: input.completed ? (existing?.task.completedAt ?? now) : null,
1586 updatedAt: now,
1587 };
1588 const subagent: ActiveCursorSubagent = {
1589 task,
1590 toolCall: input.toolCall,
1591 callId: input.callId,
1592 childThreadId,
1593 childRootNodeId,
1594 turnItemId:
1595 existing?.turnItemId ??
1596 idAllocator.derive.turnItemFromProviderItem({
1597 driver: CursorAgentSdk.CURSOR_PROVIDER,
1598 nativeItemId,
1599 }),
1600 turnItemOrdinal:
1601 existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(input.context, nativeItemId)),
1602 nextChildOrdinal: existing?.nextChildOrdinal ?? 100,
1603 resultProjected: existing?.resultProjected ?? false,
1604 };
1605 input.context.subagents.set(input.callId, subagent);
1606
1607 if (existing === undefined) {
1608 yield* emitProviderEvent({
1609 type: "app_thread.created",
1610 driver: CursorAgentSdk.CURSOR_PROVIDER,
1611 appThread: makeSubagentChildThread({
1612 parentThread: input.context.input.appThread,
1613 childThreadId,
1614 parentNodeId: nodeId,
1615 activeProviderThreadId: null,
1616 providerInstanceId: input.context.input.modelSelection.instanceId,
1617 modelSelection: input.context.input.modelSelection,
1618 title: subagentThreadTitle({
1619 parentTitle: input.context.input.appThread.title,
1620 title: args.description,
1621 prompt: args.prompt,
1622 ordinal: input.context.subagents.size,
1623 }),
1624 now,
1625 createdBy: "agent",
1626 creationSource: "provider",
1627 }),
1628 });
1629 const promptNativeId = `${nativeItemId}:prompt`;
1630 const promptArtifacts = makeSubagentConversationArtifacts({
1631 senderThreadId: input.context.input.threadId,
1632 messageId: idAllocator.derive.messageFromProviderItem({
1633 driver: CursorAgentSdk.CURSOR_PROVIDER,
1634 nativeItemId: promptNativeId,
1635 }),
1636 turnItemId: idAllocator.derive.turnItemFromProviderItem({
1637 driver: CursorAgentSdk.CURSOR_PROVIDER,
1638 nativeItemId: promptNativeId,
1639 }),
1640 threadId: childThreadId,
1641 rootNodeId: childRootNodeId,
1642 providerThreadId: null,
1643 providerTurnId: null,
1644 nativeItemRef: {
1645 driver: CursorAgentSdk.CURSOR_PROVIDER,
1646 nativeId: promptNativeId,
1647 strength: "weak",
1648 },
1649 role: "user",
1650 text: args.prompt,
1651 ordinal: 100,
1652 now,
1653 });
1654 yield* emitProviderEvent({
1655 type: "message.updated",
1656 driver: CursorAgentSdk.CURSOR_PROVIDER,
1657 message: promptArtifacts.message,
1658 });
1659 yield* emitProviderEvent({
1660 type: "turn_item.updated",
1661 driver: CursorAgentSdk.CURSOR_PROVIDER,
1662 turnItem: promptArtifacts.turnItem,
1663 });
1664 }
1665
1666 yield* emitProviderEvent({
1667 type: "node.updated",
1668 driver: CursorAgentSdk.CURSOR_PROVIDER,
1669 node: {
1670 id: nodeId,
1671 threadId: input.context.input.threadId,
1672 runId: input.context.input.runId,
1673 parentNodeId: input.context.input.rootNodeId,
1674 rootNodeId: input.context.input.rootNodeId,
1675 kind: "subagent",
1676 status,
1677 countsForRun: false,
1678 providerThreadId: input.context.input.providerThread.id,
1679 providerTurnId: input.context.providerTurnId,
1680 nativeItemRef: task.nativeTaskRef,
1681 runtimeRequestId: null,
1682 checkpointScopeId: null,
1683 startedAt: task.startedAt,
1684 completedAt: task.completedAt,
1685 },
1686 });
1687 yield* emitProviderEvent({
1688 type: "node.updated",
1689 driver: CursorAgentSdk.CURSOR_PROVIDER,
1690 node: {
1691 id: childRootNodeId,
1692 threadId: childThreadId,
1693 runId: null,
1694 parentNodeId: null,
1695 rootNodeId: childRootNodeId,
1696 kind: "root_turn",
1697 status,
1698 countsForRun: false,
1699 providerThreadId: null,
1700 providerTurnId: null,
1701 nativeItemRef: task.nativeTaskRef,
1702 runtimeRequestId: null,
1703 checkpointScopeId: null,
1704 startedAt: task.startedAt,
1705 completedAt: task.completedAt,
1706 },
1707 });
1708 yield* emitProviderEvent({
1709 type: "subagent.updated",
1710 driver: CursorAgentSdk.CURSOR_PROVIDER,
1711 subagent: task,
1712 });
1713 yield* emitProviderEvent({
1714 type: "turn_item.updated",
1715 driver: CursorAgentSdk.CURSOR_PROVIDER,
1716 turnItem: {
1717 id: subagent.turnItemId,
1718 threadId: input.context.input.threadId,
1719 runId: input.context.input.runId,
1720 nodeId,
1721 providerThreadId: input.context.input.providerThread.id,
1722 providerTurnId: input.context.providerTurnId,
1723 nativeItemRef: task.nativeTaskRef,
1724 parentItemId: null,
1725 ordinal: subagent.turnItemOrdinal,
1726 status,
1727 title: task.title,
1728 startedAt: task.startedAt,
1729 completedAt: task.completedAt,
1730 updatedAt: now,
1731 type: "subagent",
1732 subagentId: task.id,
1733 origin: task.origin,
1734 driver: task.driver,
1735 providerInstanceId: task.providerInstanceId,
1736 childThreadId,
1737 prompt: task.prompt,
1738 result: task.result,
1739 },
1740 });
1741
1742 if (input.completed && !subagent.resultProjected) {
1743 for (const [index, nestedTool] of toolCallsFromConversationSteps(
1744 result?.conversationSteps ?? [],
1745 ).entries()) {
1746 const callId = `${nativeItemId}:child-tool:${nestedTool.callId || index + 1}`;
1747 const startedAt = task.startedAt ?? now;
1748 const active: ActiveCursorToolCall = {
1749 callId,
1750 toolCall: nestedTool.toolCall,
1751 target: {
1752 threadId: childThreadId,
1753 runId: null,
1754 rootNodeId: childRootNodeId,
1755 parentNodeId: childRootNodeId,
1756 providerThreadId: null,
1757 providerTurnId: null,
1758 },
1759 ordinal: ++subagent.nextChildOrdinal,
1760 startedAt,
1761 streamedOutput: "",
1762 };
1763 yield* emitToolArtifacts({ active, completed: true });
1764 }
1765 if (task.result !== null && task.result.length > 0) {
1766 const resultNativeId = `${nativeItemId}:result`;
1767 const resultArtifacts = makeSubagentConversationArtifacts({
1768 messageId: idAllocator.derive.messageFromProviderItem({
1769 driver: CursorAgentSdk.CURSOR_PROVIDER,
1770 nativeItemId: resultNativeId,
1771 }),
1772 turnItemId: idAllocator.derive.turnItemFromProviderItem({
1773 driver: CursorAgentSdk.CURSOR_PROVIDER,
1774 nativeItemId: resultNativeId,
1775 }),
1776 threadId: childThreadId,
1777 rootNodeId: childRootNodeId,
1778 providerThreadId: null,
1779 providerTurnId: null,
1780 nativeItemRef: {
1781 driver: CursorAgentSdk.CURSOR_PROVIDER,
1782 nativeId: resultNativeId,
1783 strength: "weak",
1784 },
1785 role: "assistant",
1786 text: task.result,
1787 ordinal: ++subagent.nextChildOrdinal,
1788 now,
1789 });
1790 yield* emitProviderEvent({
1791 type: "message.updated",
1792 driver: CursorAgentSdk.CURSOR_PROVIDER,
1793 message: resultArtifacts.message,
1794 });
1795 yield* emitProviderEvent({
1796 type: "turn_item.updated",
1797 driver: CursorAgentSdk.CURSOR_PROVIDER,
1798 turnItem: resultArtifacts.turnItem,
1799 });
1800 }
1801 subagent.resultProjected = true;
1802 }
1803 });
1804
1805 const handleToolUpdate = Effect.fnUntraced(function* (
1806 context: ActiveCursorTurn,
1807 update: Extract<
1808 InteractionUpdate,
1809 {
1810 readonly type: "tool-call-started" | "partial-tool-call" | "tool-call-completed";
1811 }
1812 >,
1813 ) {
1814 const completed = update.type === "tool-call-completed";
1815 const toolCall = update.toolCall;
1816 switch (toolCall.type) {
1817 case "createPlan":
1818 yield* emitPlanArtifacts({
1819 context,
1820 callId: update.callId,
1821 markdown: toolCall.args.plan,
1822 completed,
1823 failed: completed && cursorToolFailed(toolCall),
1824 });
1825 return;
1826 case "updateTodos":
1827 yield* emitTodoArtifacts({
1828 context,
1829 callId: update.callId,
1830 toolCall,
1831 completed,
1832 failed: completed && cursorToolFailed(toolCall),
1833 });
1834 return;
1835 case "task":
1836 if (update.type === "partial-tool-call") {
1837 return;
1838 }
1839 yield* emitSubagent({
1840 context,
1841 callId: update.callId,
1842 toolCall,
1843 completed,
1844 });
1845 return;
1846 default: {
1847 const active = yield* ensureToolStarted(context, update.callId, toolCall);
1848 active.toolCall = toolCall;
1849 if (update.type !== "tool-call-started") {
1850 yield* emitToolArtifacts({ active, completed });
1851 }
1852 if (completed) {
1853 context.tools.delete(update.callId);
1854 }
1855 }
1856 }
1857 });
1858
1859 const shellOutputText = (event: Record<string, unknown>): string => {
1860 const candidates = [
1861 event.text,
1862 event.output,
1863 event.data,
1864 event.stdout,
1865 event.stderr,
1866 event.chunk,
1867 ];
1868 return candidates.filter((value): value is string => typeof value === "string").join("");
1869 };
1870
1871 const handleInteractionUpdate = Effect.fnUntraced(function* (
1872 context: ActiveCursorTurn,
1873 update: InteractionUpdate,
1874 ) {
1875 if (context.finalized) {
1876 return;
1877 }
1878 switch (update.type) {
1879 case "text-delta":
1880 context.assistantReply.push(update.text);
1881 yield* completeReasoning(context);
1882 yield* appendTextSegment({
1883 context,
1884 stream: context.assistant,
1885 kind: "assistant",
1886 text: update.text,
1887 });
1888 yield* emitAssistant(context, false);
1889 return;
1890 case "thinking-delta":
1891 yield* completeAssistant(context);
1892 yield* appendTextSegment({
1893 context,
1894 stream: context.reasoning,
1895 kind: "reasoning",
1896 text: update.text,
1897 });
1898 yield* emitReasoning(context, false);
1899 return;
1900 case "thinking-completed":
1901 yield* completeReasoning(context);
1902 return;
1903 case "tool-call-started":
1904 case "partial-tool-call":
1905 case "tool-call-completed":
1906 yield* completeAssistant(context);
1907 yield* completeReasoning(context);
1908 yield* handleToolUpdate(context, update);
1909 return;
1910 case "step-completed":
1911 case "turn-ended":
1912 yield* completeAssistant(context);
1913 yield* completeReasoning(context);
1914 return;
1915 case "shell-output-delta": {
1916 const shell = Array.from(context.tools.values())
1917 .toReversed()
1918 .find((candidate) => candidate.toolCall.type === "shell");
1919 if (shell === undefined) {
1920 return;
1921 }
1922 shell.streamedOutput += shellOutputText(update.event);
1923 yield* emitToolArtifacts({ active: shell, completed: false });
1924 return;
1925 }
1926 default:
1927 return;
1928 }
1929 });
1930
1931 const providerTurnPayload = (input: {
1932 readonly context: ActiveCursorTurn;
1933 readonly status: OrchestrationV2ProviderTurn["status"];
1934 readonly completedAt: DateTime.Utc | null;
1935 }): OrchestrationV2ProviderTurn => ({
1936 id: input.context.providerTurnId,
1937 providerThreadId: input.context.input.providerThread.id,
1938 nodeId: input.context.input.rootNodeId,
1939 runAttemptId: input.context.input.attemptId,
1940 nativeTurnRef: {
1941 driver: CursorAgentSdk.CURSOR_PROVIDER,
1942 nativeId: input.context.run.runId,
1943 strength: "strong",
1944 },
1945 ordinal: input.context.input.providerTurnOrdinal,
1946 status: input.status,
1947 startedAt: input.context.startedAt,
1948 completedAt: input.completedAt,
1949 });
1950
1951 const finalizeTurn = Effect.fnUntraced(function* (input: {
1952 readonly context: ActiveCursorTurn;
1953 readonly status: Extract<
1954 OrchestrationV2ProviderTurn["status"],
1955 "completed" | "interrupted" | "failed" | "cancelled"
1956 >;
1957 readonly failure?: OrchestrationV2ProviderFailure;
1958 readonly threadDisposition?: "reusable" | "broken";
1959 }) {
1960 if (input.context.finalized) {
1961 return;
1962 }
1963 input.context.finalized = true;
1964 const completedAt = yield* DateTime.now;
1965 // Tools still here never got a tool-call-completed. A stopped or
1966 // failed turn cut them short, so they end with the turn's status.
1967 for (const tool of input.context.tools.values()) {
1968 yield* emitToolArtifacts({
1969 active: tool,
1970 completed: true,
1971 ...(input.status === "completed" ? {} : { unfinishedStatus: input.status }),
1972 });
1973 }
1974 input.context.tools.clear();
1975 // This interaction stops delivering updates at finalization. Tasks
1976 // without a completion have an unknown outcome. Background launch
1977 // acknowledgements have already settled their rows.
1978 for (const subagent of input.context.subagents.values()) {
1979 if (!isOrchestrationV2WorkActive(subagent.task.status)) continue;
1980 yield* emitSubagent({
1981 context: input.context,
1982 callId: subagent.callId,
1983 toolCall: subagent.toolCall,
1984 completed: true,
1985 status: input.status === "completed" ? "idle" : input.status,
1986 });
1987 }
1988 yield* completeReasoning(input.context);
1989 yield* completeAssistant(input.context);
1990 yield* emitProviderEvent({
1991 type: "provider_turn.updated",
1992 driver: CursorAgentSdk.CURSOR_PROVIDER,
1993 providerTurn: providerTurnPayload({
1994 context: input.context,
1995 status: input.status,
1996 completedAt,
1997 }),
1998 });
1999 yield* emitProviderEvent({
2000 type: "provider_thread.updated",
2001 driver: CursorAgentSdk.CURSOR_PROVIDER,
2002 providerThread: {
2003 ...input.context.input.providerThread,
2004 providerSessionId: session.id,
2005 status: "active",
2006 firstRunOrdinal:
2007 input.context.input.providerThread.firstRunOrdinal ??
2008 input.context.input.runOrdinal,
2009 lastRunOrdinal: input.context.input.runOrdinal,
2010 updatedAt: completedAt,
2011 },
2012 });
2013 const threadDisposition = input.threadDisposition ?? "reusable";
2014 yield* emitProviderEvent(
2015 input.status === "failed"
2016 ? {
2017 type: "turn.terminal",
2018 driver: CursorAgentSdk.CURSOR_PROVIDER,
2019 providerThreadId: input.context.input.providerThread.id,
2020 providerTurnId: input.context.providerTurnId,
2021 runOrdinal: input.context.input.runOrdinal,
2022 failureItemOrdinal: yield* resolveItemOrdinal(
2023 input.context,
2024 `terminal-failure:${input.context.providerTurnId}`,
2025 ),
2026 status: input.status,
2027 failure: input.failure ?? makeProviderFailure({ class: "provider_error" }),
2028 threadDisposition,
2029 }
2030 : {
2031 type: "turn.terminal",
2032 driver: CursorAgentSdk.CURSOR_PROVIDER,
2033 providerThreadId: input.context.input.providerThread.id,
2034 providerTurnId: input.context.providerTurnId,
2035 runOrdinal: input.context.input.runOrdinal,
2036 status: input.status,
2037 failure: null,
2038 threadDisposition,
2039 },
2040 );
2041 yield* Ref.update(activeTurn, (current) =>
2042 current?.providerTurnId === input.context.providerTurnId ? null : current,
2043 );
2044 yield* Deferred.succeed(input.context.completed, undefined);
2045 });
2046
2047 const terminalStatus = (
2048 context: ActiveCursorTurn,
2049 result: RunResult,
2050 ): Extract<
2051 OrchestrationV2ProviderTurn["status"],
2052 "completed" | "interrupted" | "failed" | "cancelled"
2053 > => {
2054 if (context.interrupted) {
2055 return "interrupted";
2056 }
2057 switch (result.status) {
2058 case "finished":
2059 return "completed";
2060 case "cancelled":
2061 return "cancelled";
2062 case "error":
2063 return "failed";
2064 }
2065 };
2066
2067 const openAgent = Effect.fnUntraced(function* (openInput: {
2068 readonly operation: "create" | "resume";
2069 readonly threadId: ThreadId;
2070 readonly modelSelection: ModelSelection;
2071 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
2072 readonly agentId?: string;
2073 }) {
2074 const existing = yield* Ref.get(liveAgent);
2075 if (
2076 existing !== null &&
2077 openInput.operation === "resume" &&
2078 existing.nativeThreadId === openInput.agentId
2079 ) {
2080 return existing;
2081 }
2082 if (existing !== null) {
2083 yield* existing.session.close.pipe(Effect.ignore);
2084 yield* Ref.set(liveAgent, null);
2085 }
2086 const sdkSession = yield* runner.open({
2087 operation: openInput.operation,
2088 ...(openInput.agentId === undefined ? {} : { agentId: openInput.agentId }),
2089 options: makeCursorAgentOptions({
2090 ...(apiKey === undefined ? {} : { apiKey }),
2091 modelSelection: openInput.modelSelection,
2092 runtimePolicy: openInput.runtimePolicy,
2093 threadId: openInput.threadId,
2094 }),
2095 threadId: openInput.threadId,
2096 providerSessionId: input.providerSessionId,
2097 });
2098 const next = {
2099 nativeThreadId: sdkSession.agentId,
2100 session: sdkSession,
2101 } satisfies CursorLiveAgent;
2102 yield* Ref.set(liveAgent, next);
2103 return next;
2104 });
2105
2106 let cursorSkillNames: ReadonlySet<string> | undefined;
2107 const resolveUserMessage = Effect.fnUntraced(function* (
2108 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
2109 ) {
2110 const rawText = turnInput.message.text;
2111 if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) {
2112 return "/compress";
2113 }
2114 if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) {
2115 const skills = yield* discoverCursorSkills(
2116 turnInput.runtimePolicy.cwd ?? undefined,
2117 adapterOptions.environment,
2118 ).pipe(
2119 Effect.provideService(FileSystem.FileSystem, fileSystem),
2120 Effect.provideService(Path.Path, path),
2121 );
2122 cursorSkillNames = new Set(
2123 skills
2124 .filter((skill) => skill.enabled && skill.userInvocable !== false)
2125 .map((skill) => skill.name),
2126 );
2127 }
2128 const userText = t3OrchestrationPromptForFirstRun({
2129 prompt: providerMessageTextWithAttachmentPaths({
2130 text:
2131 cursorSkillNames === undefined
2132 ? rawText
2133 : rewriteCursorSkillMentions(rawText, cursorSkillNames),
2134 attachments: turnInput.message.attachments,
2135 resolveAttachmentPath: host.resolveAttachmentPath,
2136 }),
2137 runOrdinal: turnInput.runOrdinal,
2138 hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined,
2139 });
2140 const images = yield* Effect.forEach(
2141 turnInput.message.attachments.filter(isProviderNativeImageAttachment),
2142 (attachment: ChatAttachment) =>
2143 Effect.gen(function* () {
2144 const path = host.resolveAttachmentPath(attachment);
2145 if (path === null) {
2146 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
2147 driver: CursorAgentSdk.CURSOR_PROVIDER,
2148 detail: `Invalid attachment id '${attachment.id}'.`,
2149 });
2150 }
2151 const bytes = yield* fileSystem.readFile(path).pipe(
2152 Effect.mapError(
2153 (cause) =>
2154 new ProviderAdapter.ProviderAdapterProtocolError({
2155 driver: CursorAgentSdk.CURSOR_PROVIDER,
2156 detail: `Failed to read attachment '${attachment.id}'.`,
2157 payload: cause,
2158 }),
2159 ),
2160 );
2161 return {
2162 data: Buffer.from(bytes).toString("base64"),
2163 mimeType: attachment.mimeType,
2164 };
2165 }),
2166 { concurrency: 1 },
2167 );
2168 if (userText.length === 0 && images.length === 0) {
2169 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
2170 driver: CursorAgentSdk.CURSOR_PROVIDER,
2171 detail: "Cursor turn requires non-empty text or attachments.",
2172 });
2173 }
2174 const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`;
2175 return images.length === 0
2176 ? text
2177 : ({
2178 text,
2179 images,
2180 } satisfies SDKUserMessage);
2181 });
2182
2183 const startTurn = Effect.fn("CursorAdapterV2.startTurn")(
2184 function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) {
2185 const current = yield* Ref.get(activeTurn);
2186 if (current !== null) {
2187 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
2188 driver: CursorAgentSdk.CURSOR_PROVIDER,
2189 detail: `Cursor provider turn ${current.providerTurnId} is still active.`,
2190 });
2191 }
2192 const agentId = nativeThreadId(turnInput.providerThread);
2193 const agent = yield* openAgent({
2194 operation: "resume",
2195 agentId,
2196 threadId: turnInput.threadId,
2197 modelSelection: turnInput.modelSelection,
2198 runtimePolicy: turnInput.runtimePolicy,
2199 });
2200 const message = yield* resolveUserMessage(turnInput);
2201 const mcpServers = cursorMcpServers(turnInput.threadId);
2202 const pendingUpdates: Array<InteractionUpdate> = [];
2203 let context: ActiveCursorTurn | null = null;
2204 const sdkRun = yield* agent.session.send({
2205 message,
2206 options: {
2207 model: cursorSdkModelSelection(turnInput.modelSelection),
2208 mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent",
2209 ...(mcpServers === undefined ? {} : { mcpServers }),
2210 },
2211 onDelta: (update) => {
2212 if (context === null) {
2213 return Effect.sync(() => {
2214 pendingUpdates.push(update);
2215 });
2216 }
2217 return handleInteractionUpdate(context, update);
2218 },
2219 });
2220 const startedAt = yield* DateTime.now;
2221 const completed = yield* Deferred.make<void, never>();
2222 const providerTurnId = idAllocator.derive.providerTurn({
2223 driver: CursorAgentSdk.CURSOR_PROVIDER,
2224 nativeTurnId: sdkRun.runId,
2225 });
2226 context = {
2227 input: turnInput,
2228 run: sdkRun,
2229 providerTurnId,
2230 startedAt,
2231 completed,
2232 itemOrdinals: new Map(),
2233 tools: new Map(),
2234 subagents: new Map(),
2235 assistant: {
2236 current: null,
2237 nextSegment: 0,
2238 },
2239 assistantReply: new CursorTransportFailure(),
2240 reasoning: {
2241 current: null,
2242 nextSegment: 0,
2243 },
2244 interrupted: false,
2245 finalized: false,
2246 };
2247 yield* Ref.set(activeTurn, context);
2248 yield* emitProviderEvent({
2249 type: "provider_turn.updated",
2250 driver: CursorAgentSdk.CURSOR_PROVIDER,
2251 providerTurn: providerTurnPayload({
2252 context,
2253 status: "running",
2254 completedAt: null,
2255 }),
2256 });
2257 yield* emitProviderEvent({
2258 type: "provider_thread.updated",
2259 driver: CursorAgentSdk.CURSOR_PROVIDER,
2260 providerThread: {
2261 ...turnInput.providerThread,
2262 providerSessionId: session.id,
2263 status: "active",
2264 updatedAt: startedAt,
2265 },
2266 });
2267 for (const update of pendingUpdates) {
2268 yield* handleInteractionUpdate(context, update);
2269 }
2270
2271 yield* sdkRun.wait.pipe(
2272 Effect.flatMap((result) =>
2273 Effect.gen(function* () {
2274 if (
2275 context !== null &&
2276 context.assistant.nextSegment === 0 &&
2277 result.result !== undefined &&
2278 result.result.length > 0
2279 ) {
2280 context.assistantReply.push(result.result);
2281 yield* appendTextSegment({
2282 context,
2283 stream: context.assistant,
2284 kind: "assistant",
2285 text: result.result,
2286 });
2287 }
2288 if (context !== null) {
2289 const transportFailure = context.assistantReply.failure;
2290 const status =
2291 transportFailure === undefined ? terminalStatus(context, result) : "failed";
2292 yield* finalizeTurn({
2293 context,
2294 status,
2295 ...(status === "failed"
2296 ? {
2297 failure: makeProviderFailure({
2298 cause:
2299 transportFailure ?? (result as { readonly error?: unknown }).error,
2300 class:
2301 transportFailure === undefined
2302 ? "provider_error"
2303 : "transport_error",
2304 }),
2305 }
2306 : {}),
2307 });
2308 }
2309 }),
2310 ),
2311 Effect.catch((cause) =>
2312 Effect.gen(function* () {
2313 if (context !== null) {
2314 yield* finalizeTurn({
2315 context,
2316 status: context.interrupted ? "interrupted" : "failed",
2317 ...(context.interrupted
2318 ? {}
2319 : {
2320 failure: makeProviderFailure({ cause, class: "transport_error" }),
2321 }),
2322 });
2323 }
2324 yield* Effect.logWarning("orchestration-v2.cursor-run-failed", {
2325 providerSessionId: input.providerSessionId,
2326 providerThreadId: turnInput.providerThread.id,
2327 providerTurnId,
2328 cause,
2329 });
2330 }),
2331 ),
2332 Effect.forkIn(sessionScope),
2333 );
2334 },
2335 (effect, turnInput) =>
2336 effect.pipe(
2337 Effect.mapError(
2338 (cause) =>
2339 new ProviderAdapter.ProviderAdapterTurnStartError({
2340 driver: CursorAgentSdk.CURSOR_PROVIDER,
2341 threadId: turnInput.threadId,
2342 providerThreadId: turnInput.providerThread.id,
2343 runId: turnInput.runId,
2344 cause,
2345 }),
2346 ),
2347 ),
2348 );
2349
2350 const closeSession = Effect.fnUntraced(function* () {
2351 const existing = yield* Ref.get(liveAgent);
2352 if (existing !== null) {
2353 yield* existing.session.close.pipe(Effect.ignore);
2354 yield* Ref.set(liveAgent, null);
2355 }
2356 yield* runner.assertComplete.pipe(
2357 Effect.catchCause((cause) =>
2358 Effect.logWarning("orchestration-v2.cursor-runner-incomplete", {
2359 providerSessionId: input.providerSessionId,
2360 cause,
2361 }),
2362 ),
2363 );
2364 });
2365 yield* Effect.addFinalizer(() => closeSession());
2366
2367 const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
2368 instanceId: adapterOptions.instanceId,
2369 driver: CursorAgentSdk.CURSOR_PROVIDER,
2370 providerSessionId: input.providerSessionId,
2371 providerSession: session,
2372 events: Stream.fromEffectRepeat(Queue.take(events)),
2373 ensureThread: Effect.fn("CursorAdapterV2.ensureThread")(
2374 function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) {
2375 const opened = yield* openAgent({
2376 operation: "create",
2377 threadId: threadInput.threadId,
2378 modelSelection: threadInput.modelSelection,
2379 runtimePolicy: threadInput.runtimePolicy,
2380 });
2381 const now = yield* DateTime.now;
2382 return makeProviderThread({
2383 idAllocator,
2384 providerInstanceId: adapterOptions.instanceId,
2385 appThreadId: threadInput.threadId,
2386 providerSessionId: input.providerSessionId,
2387 nativeThreadId: opened.nativeThreadId,
2388 now,
2389 });
2390 },
2391 (effect, threadInput) =>
2392 effect.pipe(
2393 Effect.mapError(
2394 (cause) =>
2395 new ProviderAdapter.ProviderAdapterEnsureThreadError({
2396 driver: CursorAgentSdk.CURSOR_PROVIDER,
2397 threadId: threadInput.threadId,
2398 cause,
2399 }),
2400 ),
2401 ),
2402 ),
2403 resumeThread: Effect.fn("CursorAdapterV2.resumeThread")(
2404 function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) {
2405 const agentId = nativeThreadId(threadInput.providerThread);
2406 yield* openAgent({
2407 operation: "resume",
2408 agentId,
2409 threadId: threadInput.providerThread.appThreadId ?? input.threadId,
2410 modelSelection: input.modelSelection,
2411 runtimePolicy: input.runtimePolicy,
2412 });
2413 const now = yield* DateTime.now;
2414 return {
2415 ...threadInput.providerThread,
2416 providerSessionId: input.providerSessionId,
2417 status: "idle" as const,
2418 updatedAt: now,
2419 };
2420 },
2421 (effect, threadInput) =>
2422 effect.pipe(
2423 Effect.mapError(
2424 (cause) =>
2425 new ProviderAdapter.ProviderAdapterResumeThreadError({
2426 driver: CursorAgentSdk.CURSOR_PROVIDER,
2427 providerSessionId: input.providerSessionId,
2428 providerThreadId: threadInput.providerThread.id,
2429 cause,
2430 }),
2431 ),
2432 ),
2433 ),
2434 startTurn,
2435 compactThread: (turnInput) =>
2436 startTurn({
2437 ...turnInput,
2438 message: { ...turnInput.message, text: "/compress" },
2439 }),
2440 steerTurn: (turnInput) =>
2441 Effect.fail(
2442 new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({
2443 driver: CursorAgentSdk.CURSOR_PROVIDER,
2444 providerThreadId: turnInput.providerThread.id,
2445 }),
2446 ),
2447 interruptTurn: Effect.fn("CursorAdapterV2.interruptTurn")(
2448 function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) {
2449 const context = yield* Ref.get(activeTurn);
2450 // Stop on a settled turn: finalization already ended its tools
2451 // and subagents, so nothing of it is left running to stop.
2452 if (context === null && turnInput.requestRuntimeRestart === true) return;
2453 if (context?.providerTurnId !== turnInput.providerTurnId) {
2454 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
2455 driver: CursorAgentSdk.CURSOR_PROVIDER,
2456 detail: `Cursor provider turn ${turnInput.providerTurnId} is not active.`,
2457 });
2458 }
2459 context.interrupted = true;
2460 yield* context.run.cancel;
2461 const stopped = yield* Deferred.await(context.completed).pipe(
2462 Effect.timeoutOption("10 seconds"),
2463 );
2464 if (Option.isSome(stopped)) {
2465 return;
2466 }
2467 yield* Effect.logWarning("orchestration-v2.cursor-interrupt-timeout", {
2468 providerSessionId: input.providerSessionId,
2469 providerThreadId: turnInput.providerThread.id,
2470 providerTurnId: turnInput.providerTurnId,
2471 });
2472 yield* finalizeTurn({ context, status: "interrupted" });
2473 },
2474 (effect, turnInput) =>
2475 effect.pipe(
2476 Effect.mapError(
2477 (cause) =>
2478 new ProviderAdapter.ProviderAdapterInterruptError({
2479 driver: CursorAgentSdk.CURSOR_PROVIDER,
2480 providerThreadId: turnInput.providerThread.id,
2481 providerTurnId: turnInput.providerTurnId,
2482 cause,
2483 }),
2484 ),
2485 ),
2486 ),
2487 respondToRuntimeRequest: (requestInput) =>
2488 Effect.fail(
2489 new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
2490 driver: CursorAgentSdk.CURSOR_PROVIDER,
2491 requestId: requestInput.requestId,
2492 cause: new ProviderAdapter.ProviderAdapterProtocolError({
2493 driver: CursorAgentSdk.CURSOR_PROVIDER,
2494 detail: "Cursor Agent SDK does not expose interactive approval requests.",
2495 }),
2496 }),
2497 ),
2498 readThreadSnapshot: Effect.fn("CursorAdapterV2.readThreadSnapshot")(
2499 function* (snapshotInput) {
2500 const requestedAgentId = nativeThreadId(snapshotInput.providerThread);
2501 const agent = yield* openAgent({
2502 operation: "resume",
2503 agentId: requestedAgentId,
2504 threadId: snapshotInput.providerThread.appThreadId ?? input.threadId,
2505 modelSelection: input.modelSelection,
2506 runtimePolicy: input.runtimePolicy,
2507 });
2508 const messages = yield* agent.session.listMessages;
2509 const now = yield* DateTime.now;
2510 const threadId = snapshotInput.providerThread.appThreadId ?? input.threadId;
2511 const projectedMessages: Array<OrchestrationV2ConversationMessage> = messages.flatMap(
2512 (message) => {
2513 const text = textFromAgentMessage(message);
2514 if (text.length === 0) {
2515 return [];
2516 }
2517 return [
2518 {
2519 createdBy: message.type === "user" ? "user" : "agent",
2520 creationSource: "provider",
2521 id: idAllocator.derive.messageFromProviderItem({
2522 driver: CursorAgentSdk.CURSOR_PROVIDER,
2523 nativeItemId: message.uuid,
2524 }),
2525 threadId,
2526 runId: null,
2527 nodeId: null,
2528 role: message.type,
2529 text,
2530 attachments: [],
2531 streaming: false,
2532 createdAt: now,
2533 updatedAt: now,
2534 },
2535 ];
2536 },
2537 );
2538 return {
2539 providerThread: {
2540 ...snapshotInput.providerThread,
2541 providerSessionId: input.providerSessionId,
2542 status: "idle" as const,
2543 updatedAt: now,
2544 },
2545 providerTurns: [],
2546 messages: projectedMessages,
2547 runtimeRequests: [],
2548 providerPayload: {
2549 agentId: requestedAgentId,
2550 messages,
2551 },
2552 };
2553 },
2554 (effect, snapshotInput) =>
2555 effect.pipe(
2556 Effect.mapError(
2557 (cause) =>
2558 new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
2559 driver: CursorAgentSdk.CURSOR_PROVIDER,
2560 providerThreadId: snapshotInput.providerThread.id,
2561 cause,
2562 }),
2563 ),
2564 ),
2565 ),
2566 rollbackThread: (rollbackInput) =>
2567 Effect.fail(
2568 new ProviderAdapter.ProviderAdapterRollbackThreadError({
2569 driver: CursorAgentSdk.CURSOR_PROVIDER,
2570 providerThreadId: rollbackInput.providerThread.id,
2571 checkpointId: rollbackInput.target.checkpointId,
2572 cause: "Cursor Agent SDK does not expose conversation rollback.",
2573 }),
2574 ),
2575 forkThread: (forkInput) =>
2576 Effect.fail(
2577 new ProviderAdapter.ProviderAdapterForkThreadError({
2578 driver: CursorAgentSdk.CURSOR_PROVIDER,
2579 providerThreadId: forkInput.sourceProviderThread.id,
2580 cause: "Cursor Agent SDK does not expose native agent forks.",
2581 }),
2582 ),
2583 };
2584 return runtime;
2585 },
2586 (effect, input) =>
2587 effect.pipe(
2588 Effect.mapError(
2589 (cause) =>
2590 new ProviderAdapter.ProviderAdapterOpenSessionError({
2591 driver: CursorAgentSdk.CURSOR_PROVIDER,
2592 providerSessionId: input.providerSessionId,
2593 cause,
2594 }),
2595 ),
2596 ),
2597 ),
2598 });
2599}
2600
2601export type CursorAdapterV2DriverEnv =
2602 | CursorAgentSdk.CursorAgentSdkRunner
2603 | FileSystem.FileSystem
2604 | Path.Path
2605 | IdAllocator.IdAllocatorV2
2606 | ProviderHost.ProviderHost;
2607
2608export const CursorAdapterV2Driver: ProviderAdapterDriver<
2609 CursorSettings,
2610 CursorAdapterV2DriverEnv
2611> = {
2612 driverKind: CURSOR_DRIVER_KIND,
2613 configSchema: CursorSettings,
2614 defaultConfig: (): CursorSettings => DEFAULT_CURSOR_SETTINGS,
2615 create: Effect.fn("CursorAdapterV2Driver.create")(
2616 function* (input: ProviderAdapterDriverCreateInput<CursorSettings>) {
2617 const hostEnvironment = yield* HostProcessEnvironment;
2618 const fileSystem = yield* FileSystem.FileSystem;
2619 const path = yield* Path.Path;
2620 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2621 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
2622 const host = yield* ProviderHost.ProviderHost;
2623 return makeCursorAdapterV2({
2624 instanceId: input.instanceId,
2625 settings: {
2626 ...input.config,
2627 enabled: input.enabled,
2628 },
2629 environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
2630 fileSystem,
2631 path,
2632 idAllocator,
2633 runner,
2634 host,
2635 });
2636 },
2637 (effect, input) =>
2638 effect.pipe(
2639 Effect.mapError(
2640 (cause) =>
2641 new ProviderAdapterDriverCreateError({
2642 driver: CURSOR_DRIVER_KIND,
2643 instanceId: input.instanceId,
2644 detail: "Failed to create Cursor Agent SDK adapter.",
2645 cause,
2646 }),
2647 ),
2648 ),
2649 ),
2650};
2651
2652const layer: Layer.Layer<
2653 ProviderAdapter.ProviderAdapterV2,
2654 never,
2655 | CursorAgentSdk.CursorAgentSdkRunner
2656 | FileSystem.FileSystem
2657 | Path.Path
2658 | IdAllocator.IdAllocatorV2
2659 | ProviderHost.ProviderHost
2660> = Layer.effect(
2661 ProviderAdapter.ProviderAdapterV2,
2662 Effect.gen(function* () {
2663 const hostEnvironment = yield* HostProcessEnvironment;
2664 const fileSystem = yield* FileSystem.FileSystem;
2665 const path = yield* Path.Path;
2666 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2667 const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
2668 const host = yield* ProviderHost.ProviderHost;
2669 return makeCursorAdapterV2({
2670 instanceId: CURSOR_DEFAULT_INSTANCE_ID,
2671 settings: DEFAULT_CURSOR_SETTINGS,
2672 environment: hostEnvironment,
2673 fileSystem,
2674 path,
2675 idAllocator,
2676 runner,
2677 host,
2678 });
2679 }),
2680);
packages/provider-cursor/src/server/auth.test.ts created+356
...@@ -0,0 +1,356 @@
1import type { SdkLoginOptions, StoredSdkCredentials } from "@cursor/sdk";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts";
5import * as Clock from "effect/Clock";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as Fiber from "effect/Fiber";
10import * as Option from "effect/Option";
11import * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14
15import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts";
16import { InMemoryCredentialStore } from "./sdk.ts";
17
18const instanceId = ProviderInstanceId.make("cursor-personal");
19const owner = "owner-client";
20const otherOwner = "other-client";
21const authorizationUrl = "https://cursor.com/loginDeepControl?challenge=test-challenge";
22
23const makeHarness = Effect.fn("makeCursorAuthHarness")(function* (
24 overrides: Partial<CursorAuthOptions> = {},
25) {
26 const store = new InMemoryCredentialStore();
27 const complete = Promise.withResolvers<void>();
28 const returned = Promise.withResolvers<void>();
29 const now = yield* Clock.currentTimeMillis;
30 const credentials: StoredSdkCredentials = {
31 version: 1,
32 backendUrl: "https://api2.cursor.sh",
33 apiKey: "browser-key",
34 createdAtMs: now,
35 apiKeyExpiresAtMs: now + 86_400_000,
36 email: "cursor@example.com",
37 };
38 let loginOptions: SdkLoginOptions | undefined;
39 const changes: boolean[] = [];
40 const auth = yield* makeCursorAuth({
41 instanceId,
42 displayName: "Personal Cursor",
43 enabled: true,
44 store,
45 onChanged: (signedIn) =>
46 Effect.sync(() => {
47 changes.push(signedIn);
48 }),
49 login: async (options) => {
50 loginOptions = options;
51 options.onLoginUrl?.(authorizationUrl);
52 await complete.promise;
53 await options.store?.save(credentials);
54 returned.resolve();
55 return {
56 apiKey: credentials.apiKey,
57 apiKeyExpiresAtMs: credentials.apiKeyExpiresAtMs!,
58 email: credentials.email!,
59 };
60 },
61 ...overrides,
62 });
63 return {
64 auth,
65 store,
66 complete,
67 returned,
68 credentials,
69 changes,
70 loginOptions: () => loginOptions,
71 };
72});
73
74type Auth = Effect.Success<ReturnType<typeof makeCursorAuth>>;
75const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) =>
76 auth.controller.subscribe(session).pipe(
77 Stream.filter((state) => state.phase === phase),
78 Stream.runHead,
79 Effect.map(Option.getOrThrow),
80 );
81
82it.layer(NodeServices.layer)("CursorAuth", (it) => {
83 it.effect("closes SDK sessions and interrupts in-flight credential work before signing out", () =>
84 Effect.gen(function* () {
85 const harness = yield* makeHarness();
86 yield* Effect.promise(() => harness.store.save(harness.credentials));
87 const acquired = yield* Deferred.make<void>();
88 let closedSession = false;
89 let closedRequest = false;
90 yield* harness.auth.withAccess(
91 Effect.acquireRelease(Effect.succeed("session"), () =>
92 Effect.sync(() => {
93 closedSession = true;
94 }),
95 ),
96 );
97 const request = yield* harness.auth
98 .withAccess(
99 Effect.gen(function* () {
100 yield* Effect.addFinalizer(() =>
101 Effect.sync(() => {
102 closedRequest = true;
103 }),
104 );
105 yield* Deferred.succeed(acquired, undefined);
106 return yield* Effect.never;
107 }),
108 )
109 .pipe(Effect.forkScoped);
110 yield* Deferred.await(acquired);
111 yield* harness.auth.controller.logout(Effect.void);
112 expect(closedSession).toBe(true);
113 expect(closedRequest).toBe(true);
114 expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true);
115 expect(yield* harness.auth.readApiKey).toBeUndefined();
116 }).pipe(Effect.scoped),
117 );
118
119 it.effect("completes a remote browser login, saves its key and refreshes provider status", () =>
120 Effect.gen(function* () {
121 const harness = yield* makeHarness();
122 let stopped = false;
123 expect(yield* harness.auth.readApiKey).toBeUndefined();
124 const starting = yield* harness.auth.controller.start(
125 owner,
126 Effect.sync(() => {
127 stopped = true;
128 }),
129 );
130 const waiting = yield* phase(harness.auth, "waiting");
131 expect(stopped).toBe(true);
132 expect(waiting.authorizationUrl).toBe(authorizationUrl);
133 expect(waiting.flowId).toBe(starting.flowId);
134 expect(harness.loginOptions()?.openBrowser).toBe(false);
135 expect((yield* harness.auth.controller.start(owner)).flowId).toBe(starting.flowId);
136 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
137 yield* Effect.sync(() => harness.complete.resolve());
138 const succeeded = yield* phase(harness.auth, "succeeded");
139 expect(succeeded.authorizationUrl).toBeNull();
140 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
141 expect(harness.changes).toEqual([true]);
142 expect(Object.values(succeeded)).not.toContain("browser-key");
143 expect(yield* Effect.promise(() => harness.store.load())).toEqual(harness.credentials);
144 }).pipe(Effect.scoped),
145 );
146
147 it.effect("keeps the login link private to its owner and rejects other clients' mutations", () =>
148 Effect.gen(function* () {
149 const { auth } = yield* makeHarness();
150 yield* auth.controller.start(owner);
151 const waiting = yield* phase(auth, "waiting");
152 const foreign = yield* phase(auth, "waiting", otherOwner);
153 expect(foreign).toMatchObject({ flowId: null, authorizationUrl: null, expiresAt: null });
154 expect(Exit.isFailure(yield* Effect.exit(auth.controller.start(otherOwner)))).toBe(true);
155 expect(
156 Exit.isFailure(yield* Effect.exit(auth.controller.cancel(otherOwner, waiting.flowId!))),
157 ).toBe(true);
158 expect(
159 Exit.isFailure(
160 yield* Effect.exit(
161 auth.controller.complete(owner, {
162 flowId: waiting.flowId!,
163 callbackUrl: "http://localhost/callback",
164 }),
165 ),
166 ),
167 ).toBe(true);
168 expect((yield* phase(auth, "waiting")).authorizationUrl).toBe(authorizationUrl);
169 }).pipe(Effect.scoped),
170 );
171
172 it.effect("aborts cancellation and ignores a late successful SDK login", () =>
173 Effect.gen(function* () {
174 const harness = yield* makeHarness();
175 yield* harness.auth.controller.start(owner);
176 const waiting = yield* phase(harness.auth, "waiting");
177 const cancelled = yield* harness.auth.controller.cancel(owner, waiting.flowId!);
178 expect(cancelled.phase).toBe("cancelled");
179 expect(cancelled.authorizationUrl).toBeNull();
180 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
181 yield* Effect.sync(() => harness.complete.resolve());
182 yield* Effect.promise(() => harness.returned.promise);
183 expect(yield* harness.auth.readApiKey).toBeUndefined();
184 expect(harness.changes).toEqual([]);
185 }).pipe(Effect.scoped),
186 );
187
188 it.effect("times out pending logins and permits a retry", () =>
189 Effect.gen(function* () {
190 const harness = yield* makeHarness();
191 yield* harness.auth.controller.start(owner);
192 const waiting = yield* phase(harness.auth, "waiting");
193 yield* TestClock.adjust(300_000);
194 expect((yield* phase(harness.auth, "failed")).authorizationUrl).toBeNull();
195 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
196 const retry = yield* harness.auth.controller.start(owner);
197 expect(retry.flowId).not.toBe(waiting.flowId);
198 }).pipe(Effect.scoped),
199 );
200
201 it.effect("finishes saving a completed login atomically across the timeout", () =>
202 Effect.gen(function* () {
203 const store = new InMemoryCredentialStore();
204 const saving = Promise.withResolvers<void>();
205 const finishSave = Promise.withResolvers<void>();
206 const scope = yield* Scope.make();
207 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
208 const harness = yield* makeHarness({
209 store: {
210 load: () => store.load(),
211 clear: () => store.clear(),
212 save: async (credentials) => {
213 saving.resolve();
214 await finishSave.promise;
215 await store.save(credentials);
216 },
217 },
218 }).pipe(Effect.provideService(Scope.Scope, scope));
219 yield* Effect.addFinalizer(() => Effect.sync(() => finishSave.resolve()));
220 yield* harness.auth.controller.start(owner);
221 yield* phase(harness.auth, "waiting");
222 yield* Effect.sync(() => harness.complete.resolve());
223 yield* Effect.promise(() => saving.promise);
224 yield* TestClock.adjust(300_000);
225 yield* Effect.sync(() => finishSave.resolve());
226 yield* phase(harness.auth, "succeeded");
227 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
228 yield* Scope.close(scope, Exit.void);
229 const state = yield* harness.auth.controller.subscribe(owner).pipe(Stream.runHead);
230 expect(Option.getOrThrow(state).phase).toBe("succeeded");
231 expect(harness.changes).toEqual([true]);
232 }).pipe(Effect.scoped),
233 );
234
235 it.effect("discards a credential rejected by verification and permits a fresh login", () =>
236 Effect.gen(function* () {
237 let rejected = true;
238 const harness = yield* makeHarness({
239 onChanged: (signedIn) =>
240 signedIn && rejected
241 ? Effect.fail(
242 new ProviderSetupError({
243 instanceId,
244 operation: "start",
245 detail: "Could not verify the Cursor sign-in.",
246 }),
247 )
248 : Effect.void,
249 });
250 yield* harness.auth.controller.start(owner);
251 yield* phase(harness.auth, "waiting");
252 yield* Effect.sync(() => harness.complete.resolve());
253 yield* phase(harness.auth, "failed");
254 expect(yield* Effect.promise(() => harness.store.load())).toBeUndefined();
255 expect(yield* harness.auth.readApiKey).toBeUndefined();
256 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
257 rejected = false;
258 yield* harness.auth.controller.start(owner);
259 yield* phase(harness.auth, "succeeded");
260 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
261 }).pipe(Effect.scoped),
262 );
263
264 it.effect("clears only this instance's credential after its sessions stop", () =>
265 Effect.gen(function* () {
266 const harness = yield* makeHarness();
267 const other = yield* makeHarness({ instanceId: ProviderInstanceId.make("cursor-work") });
268 yield* Effect.promise(() => harness.store.save(harness.credentials));
269 yield* Effect.promise(() => other.store.save({ ...other.credentials, apiKey: "other-key" }));
270 const state = yield* harness.auth.controller.logout(
271 Effect.gen(function* () {
272 expect(yield* harness.auth.readApiKey).toBe("browser-key");
273 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
274 }),
275 );
276 expect(state.message).toBe("Signed out of Cursor.");
277 expect(yield* harness.auth.readApiKey).toBeUndefined();
278 expect(yield* other.auth.requireApiKey).toBe("other-key");
279 expect(harness.changes).toEqual([false]);
280 }).pipe(Effect.scoped),
281 );
282
283 it.effect("cancels a pending sign-in on logout without saving its eventual key", () =>
284 Effect.gen(function* () {
285 const harness = yield* makeHarness();
286 yield* harness.auth.controller.start(owner);
287 yield* phase(harness.auth, "waiting");
288 yield* harness.auth.controller.logout(Effect.void);
289 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
290 yield* Effect.sync(() => harness.complete.resolve());
291 yield* Effect.promise(() => harness.returned.promise);
292 expect(yield* harness.auth.readApiKey).toBeUndefined();
293 }).pipe(Effect.scoped),
294 );
295
296 it.effect("retains credentials if sessions cannot be stopped for logout", () =>
297 Effect.gen(function* () {
298 const harness = yield* makeHarness();
299 yield* Effect.promise(() => harness.store.save(harness.credentials));
300 const result = yield* Effect.exit(
301 harness.auth.controller.logout(
302 Effect.fail(
303 new ProviderSetupError({
304 instanceId,
305 operation: "stop",
306 detail: "Unable to stop sessions",
307 }),
308 ),
309 ),
310 );
311 expect(Exit.isFailure(result)).toBe(true);
312 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
313 }).pipe(Effect.scoped),
314 );
315
316 it.effect(
317 "prefers explicit API keys and prevents browser login from silently overriding them",
318 () =>
319 Effect.gen(function* () {
320 const harness = yield* makeHarness({ apiKey: " explicit-key " });
321 yield* Effect.promise(() => harness.store.save(harness.credentials));
322 expect(yield* harness.auth.requireApiKey).toBe("explicit-key");
323 expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true);
324 expect(
325 Exit.isFailure(yield* Effect.exit(harness.auth.controller.logout(Effect.void))),
326 ).toBe(true);
327 expect(harness.loginOptions()).toBeUndefined();
328 expect((yield* Effect.promise(() => harness.store.load()))?.apiKey).toBe("browser-key");
329 }).pipe(Effect.scoped),
330 );
331
332 it.effect("ignores expired browser credentials and rejects disabled sign-in", () =>
333 Effect.gen(function* () {
334 const harness = yield* makeHarness({ enabled: false });
335 yield* Effect.promise(() =>
336 harness.store.save({ ...harness.credentials, apiKeyExpiresAtMs: 0 }),
337 );
338 expect(yield* harness.auth.readApiKey).toBeUndefined();
339 expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true);
340 }).pipe(Effect.scoped),
341 );
342
343 it.effect("aborts pending login when the provider instance is disposed", () =>
344 Effect.gen(function* () {
345 const scope = yield* Scope.make();
346 const harness = yield* makeHarness().pipe(Effect.provideService(Scope.Scope, scope));
347 yield* harness.auth.controller.start(owner);
348 yield* phase(harness.auth, "waiting");
349 yield* Scope.close(scope, Exit.void);
350 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
351 yield* Effect.sync(() => harness.complete.resolve());
352 yield* Effect.promise(() => harness.returned.promise);
353 expect(yield* harness.auth.readApiKey).toBeUndefined();
354 }),
355 );
356});
packages/provider-cursor/src/server/auth.ts created+495
...@@ -0,0 +1,495 @@
1import type { SdkCredentialStore, SdkLoginOptions, SdkLoginResult } from "@cursor/sdk";
2import {
3 ProviderSetupError,
4 type ProviderAuthState,
5 type ProviderInstanceId,
6} from "@t3tools/contracts";
7import * as Clock from "effect/Clock";
8import * as Crypto from "effect/Crypto";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";
13import * as Queue from "effect/Queue";
14import * as Scope from "effect/Scope";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18
19import { Cursor, InMemoryCredentialStore } from "./sdk.ts";
20import type { ProviderAuthController } from "@t3tools/provider-core/server/auth";
21
22const AUTH_TIMEOUT_MS = 300_000;
23
24interface AuthFlow {
25 readonly id: string;
26 readonly owner: string;
27 fiber?: Fiber.Fiber<void>;
28}
29
30export interface CursorAuth {
31 readonly controller: ProviderAuthController;
32 readonly readApiKey: Effect.Effect<string | undefined, ProviderSetupError>;
33 readonly requireApiKey: Effect.Effect<string, ProviderSetupError>;
34 readonly usesApiKey: boolean;
35 readonly withAccess: <A, E, R>(
36 task: Effect.Effect<A, E, R>,
37 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
38}
39
40export interface CursorAuthOptions {
41 readonly instanceId: ProviderInstanceId;
42 readonly displayName: string;
43 readonly enabled: boolean;
44 readonly apiKey?: string;
45 readonly store: SdkCredentialStore;
46 readonly credentialBinding?: NonNullable<ProviderAuthController["credentialBinding"]>;
47 readonly onChanged: (signedIn: boolean) => Effect.Effect<void, ProviderSetupError>;
48 readonly login?: (options: SdkLoginOptions) => Promise<SdkLoginResult>;
49}
50
51/** Each instance owns its browser credential; explicit API keys always take precedence. */
52export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) {
53 const scope = yield* Scope.Scope;
54 const crypto = yield* Crypto.Crypto;
55 const lock = yield* Semaphore.make(1);
56 const configuredKey = options.apiKey?.trim() || undefined;
57 const emptyState: ProviderAuthState = {
58 instanceId: options.instanceId,
59 phase: "idle",
60 flowId: null,
61 authorizationUrl: null,
62 expiresAt: null,
63 message: null,
64 };
65 const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: emptyState });
66 let active: AuthFlow | undefined;
67 let operation: "idle" | "login" | "stopping" | "closed" = "idle";
68 const sessions = new Set<Scope.Closeable>();
69
70 const publish = (flow: AuthFlow, patch: Partial<ProviderAuthState>) =>
71 SubscriptionRef.update(snapshot, (current) => ({
72 owner: flow.owner,
73 state: { ...current.state, ...patch },
74 }));
75
76 const readApiKey = Effect.gen(function* () {
77 if (configuredKey) return configuredKey;
78 const credentials = yield* Effect.tryPromise({
79 try: () => options.store.load(),
80 catch: (cause) =>
81 new ProviderSetupError({
82 instanceId: options.instanceId,
83 operation: "credentials",
84 detail: "Could not read the Cursor sign-in. Try signing in again.",
85 cause,
86 }),
87 });
88 const now = yield* Clock.currentTimeMillis;
89 return credentials &&
90 (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now)
91 ? credentials.apiKey
92 : undefined;
93 });
94
95 const requireApiKey = Effect.gen(function* () {
96 if (operation !== "idle") {
97 return yield* new ProviderSetupError({
98 instanceId: options.instanceId,
99 operation: "credentials",
100 detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.",
101 });
102 }
103 const apiKey = yield* readApiKey;
104 if (!apiKey) {
105 return yield* new ProviderSetupError({
106 instanceId: options.instanceId,
107 operation: "credentials",
108 detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
109 });
110 }
111 return apiKey;
112 });
113
114 // Keep SDK sessions and one-shot text generation inside instance-owned scopes.
115 // Closing admission before draining these scopes prevents cached credentials surviving sign-out.
116 const withAccess: CursorAuth["withAccess"] = (task) =>
117 Effect.uninterruptibleMask((restore) =>
118 Effect.gen(function* () {
119 const parent = yield* Scope.Scope;
120 const child = yield* lock.withPermits(1)(
121 Effect.gen(function* () {
122 if (operation !== "idle")
123 return yield* new ProviderSetupError({
124 instanceId: options.instanceId,
125 operation: "session",
126 detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.",
127 });
128 const child = yield* Scope.make();
129 sessions.add(child);
130 yield* Scope.addFinalizer(
131 child,
132 Effect.sync(() => {
133 sessions.delete(child);
134 }),
135 );
136 yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void));
137 return child;
138 }),
139 );
140 const fiber = yield* restore(task).pipe(
141 Effect.provideService(Scope.Scope, child),
142 Effect.forkIn(child),
143 );
144 return yield* restore(Fiber.await(fiber)).pipe(
145 Effect.flatMap((result) => result),
146 Effect.onExit((result) =>
147 Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void,
148 ),
149 );
150 }),
151 );
152 const stopSessionsWithCredentials = Effect.suspend(() =>
153 Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), {
154 discard: true,
155 concurrency: "unbounded",
156 }),
157 );
158
159 const runLogin = (flow: AuthFlow, stopSessions: Effect.Effect<void, ProviderSetupError>) =>
160 Effect.gen(function* () {
161 yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials));
162 // The SDK first writes into memory. Cancellation must not save a late login result.
163 const pendingStore = new InMemoryCredentialStore();
164 const urls = yield* Queue.unbounded<string>();
165 yield* Queue.take(urls).pipe(
166 Effect.flatMap((authorizationUrl) =>
167 lock.withPermits(1)(
168 Effect.gen(function* () {
169 if (active !== flow) return;
170 yield* publish(flow, {
171 phase: "waiting",
172 authorizationUrl,
173 message:
174 "Open the Cursor sign-in page and finish signing in. This page updates automatically.",
175 });
176 }),
177 ),
178 ),
179 Effect.forever,
180 Effect.forkScoped,
181 );
182 yield* Effect.tryPromise({
183 try: (signal) =>
184 (options.login ?? Cursor.auth.login)({
185 openBrowser: false,
186 store: pendingStore,
187 signal,
188 apiKeyName: `T3 Code - ${options.displayName}`,
189 onLoginUrl: (authorizationUrl) => {
190 if (active === flow) Queue.offerUnsafe(urls, authorizationUrl);
191 },
192 }),
193 catch: (cause) =>
194 new ProviderSetupError({
195 instanceId: options.instanceId,
196 operation: "start",
197 detail: "Cursor sign-in failed. Start sign-in again.",
198 cause,
199 }),
200 });
201 // Commit and publish together; interrupting a store Promise cannot stop a late write.
202 yield* lock.withPermits(1)(
203 Effect.gen(function* () {
204 if (active !== flow) return;
205 yield* publish(flow, {
206 phase: "verifying",
207 authorizationUrl: null,
208 message: "Checking Cursor sign-in.",
209 });
210 yield* Effect.tryPromise({
211 try: async () => {
212 const credentials = await pendingStore.load();
213 if (!credentials) throw new Error("Cursor login did not return credentials");
214 await options.store.save(credentials);
215 },
216 catch: (cause) =>
217 new ProviderSetupError({
218 instanceId: options.instanceId,
219 operation: "start",
220 detail: "Could not save the Cursor sign-in. Try again.",
221 cause,
222 }),
223 });
224 const verified = yield* options.onChanged(true).pipe(Effect.exit);
225 if (Exit.isFailure(verified)) {
226 yield* Effect.tryPromise({
227 try: () => options.store.clear(),
228 catch: (cause) =>
229 new ProviderSetupError({
230 instanceId: options.instanceId,
231 operation: "start",
232 detail: "Could not clear the rejected Cursor sign-in. Try signing out.",
233 cause,
234 }),
235 });
236 return yield* Effect.failCause(verified.cause);
237 }
238 active = undefined;
239 operation = "idle";
240 yield* publish(flow, {
241 phase: "succeeded",
242 authorizationUrl: null,
243 expiresAt: null,
244 message: "Signed in with Cursor.",
245 });
246 }).pipe(Effect.uninterruptible),
247 );
248 }).pipe(
249 Effect.scoped,
250 Effect.timeoutOrElse({
251 duration: AUTH_TIMEOUT_MS,
252 orElse: () =>
253 Effect.fail(
254 new ProviderSetupError({
255 instanceId: options.instanceId,
256 operation: "start",
257 detail: "Cursor sign-in expired. Start sign-in again.",
258 }),
259 ),
260 }),
261 Effect.exit,
262 Effect.flatMap((result) =>
263 lock.withPermits(1)(
264 Effect.gen(function* () {
265 if (active !== flow) return;
266 active = undefined;
267 operation = "idle";
268 yield* publish(flow, {
269 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
270 authorizationUrl: null,
271 expiresAt: null,
272 message: Exit.isSuccess(result)
273 ? "Signed in with Cursor."
274 : "Cursor sign-in failed or expired. Start sign-in again.",
275 });
276 }),
277 ),
278 ),
279 );
280
281 const requireFlow = (owner: string, id: string, name: string) =>
282 Effect.gen(function* () {
283 if (!active || active.owner !== owner || active.id !== id) {
284 return yield* new ProviderSetupError({
285 instanceId: options.instanceId,
286 operation: name,
287 detail: "This sign-in is no longer active in this client.",
288 });
289 }
290 return active;
291 });
292
293 const controller: ProviderAuthController = {
294 credentialBinding: options.credentialBinding ?? {
295 owner: "t3",
296 key: `cursor:${options.instanceId}`,
297 },
298 isChangingCredentials: Effect.sync(() => operation !== "idle"),
299 start: (owner, stopSessions = Effect.void) =>
300 lock.withPermits(1)(
301 Effect.uninterruptible(
302 Effect.gen(function* () {
303 if (!options.enabled)
304 return yield* new ProviderSetupError({
305 instanceId: options.instanceId,
306 operation: "start",
307 detail: "Enable Cursor before signing in.",
308 });
309 if (configuredKey)
310 return yield* new ProviderSetupError({
311 instanceId: options.instanceId,
312 operation: "start",
313 detail:
314 "Remove CURSOR_API_KEY from this provider's environment before using browser sign-in.",
315 });
316 if (active?.owner === owner && operation === "login") return snapshot.value.state;
317 if (operation !== "idle")
318 return yield* new ProviderSetupError({
319 instanceId: options.instanceId,
320 operation: "start",
321 detail: "Cursor setup is already in progress.",
322 });
323 const id = yield* crypto.randomUUIDv4.pipe(
324 Effect.mapError(
325 (cause) =>
326 new ProviderSetupError({
327 instanceId: options.instanceId,
328 operation: "start",
329 detail: "Could not start Cursor sign-in. Try again.",
330 cause,
331 }),
332 ),
333 );
334 const expiresAt = DateTime.formatIso(
335 DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS),
336 );
337 const flow: AuthFlow = { id, owner };
338 active = flow;
339 operation = "login";
340 const state: ProviderAuthState = {
341 ...emptyState,
342 phase: "starting",
343 flowId: id,
344 expiresAt,
345 message: "Starting Cursor sign-in.",
346 };
347 yield* SubscriptionRef.set(snapshot, { owner, state });
348 flow.fiber = yield* runLogin(flow, stopSessions).pipe(
349 Effect.interruptible,
350 Effect.forkIn(scope),
351 );
352 return state;
353 }),
354 ),
355 ),
356 complete: () =>
357 Effect.fail(
358 new ProviderSetupError({
359 instanceId: options.instanceId,
360 operation: "complete",
361 detail: "Finish signing in on the Cursor website. No redirect URL is needed.",
362 }),
363 ),
364 cancel: (owner, id) =>
365 Effect.uninterruptible(
366 Effect.gen(function* () {
367 const flow = yield* lock.withPermits(1)(
368 Effect.gen(function* () {
369 const flow = yield* requireFlow(owner, id, "cancel");
370 active = undefined;
371 operation = "stopping";
372 yield* publish(flow, {
373 phase: "cancelled",
374 authorizationUrl: null,
375 expiresAt: null,
376 message: "Cursor sign-in was cancelled.",
377 });
378 return flow;
379 }),
380 );
381 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
382 operation = "idle";
383 return snapshot.value.state;
384 }),
385 ),
386 logout: (stopSessions) =>
387 Effect.uninterruptible(
388 Effect.gen(function* () {
389 const flow = yield* lock.withPermits(1)(
390 Effect.gen(function* () {
391 if (configuredKey)
392 return yield* new ProviderSetupError({
393 instanceId: options.instanceId,
394 operation: "logout",
395 detail:
396 "Remove CURSOR_API_KEY from this provider's environment to disconnect it.",
397 });
398 if (operation !== "idle" && operation !== "login")
399 return yield* new ProviderSetupError({
400 instanceId: options.instanceId,
401 operation: "logout",
402 detail: "Cursor setup is already stopping.",
403 });
404 operation = "stopping";
405 const flow = active;
406 active = undefined;
407 return flow;
408 }),
409 );
410 const result = yield* Effect.gen(function* () {
411 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
412 yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials));
413 yield* Effect.tryPromise({
414 try: () => options.store.clear(),
415 catch: (cause) =>
416 new ProviderSetupError({
417 instanceId: options.instanceId,
418 operation: "logout",
419 detail: "Could not clear the Cursor sign-in. Try again.",
420 cause,
421 }),
422 });
423 yield* options.onChanged(false);
424 }).pipe(Effect.exit);
425 operation = "idle";
426 const state: ProviderAuthState = {
427 ...emptyState,
428 phase: Exit.isSuccess(result) ? "idle" : "failed",
429 message: Exit.isSuccess(result)
430 ? "Signed out of Cursor."
431 : "Cursor sign-out failed. Try again.",
432 };
433 yield* SubscriptionRef.set(snapshot, { owner: null, state });
434 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
435 return state;
436 }),
437 ),
438 subscribe: (owner) =>
439 SubscriptionRef.changes(snapshot).pipe(
440 Stream.map((current) => {
441 const state = {
442 ...current.state,
443 credentialOwner: "t3" as const,
444 methods: [
445 {
446 id: "browser",
447 name: "Sign in with Cursor",
448 description: null,
449 type: "agent" as const,
450 },
451 ],
452 interaction:
453 current.state.phase === "waiting" &&
454 current.state.authorizationUrl &&
455 current.state.flowId
456 ? {
457 type: "browser" as const,
458 id: current.state.flowId,
459 url: current.state.authorizationUrl,
460 requiresConsent: false,
461 }
462 : null,
463 };
464 if (current.owner === null || current.owner === owner) return state;
465 return {
466 ...state,
467 interaction: null,
468 flowId: null,
469 authorizationUrl: null,
470 expiresAt: null,
471 message: active ? "Sign-in is in progress in another client." : current.state.message,
472 };
473 }),
474 ),
475 isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout",
476 };
477
478 yield* Effect.addFinalizer(() =>
479 Effect.gen(function* () {
480 operation = "closed";
481 const flow = active;
482 active = undefined;
483 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
484 yield* stopSessionsWithCredentials;
485 }),
486 );
487
488 return {
489 controller,
490 readApiKey,
491 requireApiKey,
492 withAccess,
493 usesApiKey: configuredKey !== undefined,
494 } satisfies CursorAuth;
495});
packages/provider-cursor/src/server/credentialStore.test.ts created+106
...@@ -0,0 +1,106 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Option from "effect/Option";
8
9import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
10import { layerTestProviderHost } from "@t3tools/provider-testing/host";
11import * as Layer from "effect/Layer";
12import { makeCursorCredentialStore } from "./credentialStore.ts";
13
14const layerHost = Layer.provideMerge(layerTestProviderHost(), NodeServices.layer);
15
16const legacyCredentials = {
17 version: 1 as const,
18 backendUrl: "https://api.cursor.com",
19 apiKey: "test-only-legacy-key",
20 createdAtMs: 100,
21};
22// The SDK's FileCredentialStore writes pretty-printed JSON.
23const legacyFileText = `{
24 "version": 1,
25 "backendUrl": "https://api.cursor.com",
26 "apiKey": "test-only-legacy-key",
27 "createdAtMs": 100
28}`;
29
30it.effect.each([
31 {
32 name: "imports a legacy sign-in",
33 legacy: legacyFileText,
34 stored: undefined,
35 expected: "test-only-legacy-key",
36 },
37 {
38 name: "keeps a newer stored sign-in",
39 legacy: legacyFileText,
40 stored: legacyFileText.replace("test-only-legacy-key", "test-only-current-key"),
41 expected: "test-only-current-key",
42 },
43 {
44 name: "replaces a damaged stored sign-in",
45 legacy: legacyFileText,
46 stored: "damaged",
47 expected: "test-only-legacy-key",
48 },
49 {
50 name: "discards a damaged legacy file",
51 legacy: "damaged",
52 stored: undefined,
53 expected: undefined,
54 },
55])("$name and deletes the legacy file", ({ legacy, stored, expected }) =>
56 Effect.gen(function* () {
57 const fileSystem = yield* FileSystem.FileSystem;
58 const path = yield* Path.Path;
59 const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json");
60 yield* fileSystem.writeFileString(legacyFile, legacy);
61 const host = yield* ProviderHost.ProviderHost;
62 const instanceId = ProviderInstanceId.make("personal");
63 if (stored !== undefined) {
64 yield* (yield* host.credentials("cursor", instanceId)).set(new TextEncoder().encode(stored));
65 }
66 const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile);
67 assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected);
68 assert.isFalse(yield* fileSystem.exists(legacyFile));
69 }).pipe(Effect.scoped, Effect.provide(layerHost)),
70);
71
72it.effect(
73 "restores SDK credentials in a new controller and keeps another account when signing out",
74 () =>
75 Effect.gen(function* () {
76 const host = yield* ProviderHost.ProviderHost;
77 const makeStore = (id: string) => makeCursorCredentialStore(ProviderInstanceId.make(id));
78 const personal = yield* makeStore("personal");
79 const work = yield* makeStore("work");
80 const credentials = {
81 version: 1 as const,
82 backendUrl: "https://api.cursor.com",
83 apiKey: "test-only-key",
84 createdAtMs: 100,
85 apiKeyExpiresAtMs: 1000,
86 email: "test@example.com",
87 };
88 yield* Effect.tryPromise(() => personal.store.save(credentials));
89 yield* Effect.tryPromise(() =>
90 work.store.save({ ...credentials, apiKey: "test-only-work-key" }),
91 );
92 const restored = yield* makeStore("personal");
93 assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials);
94 yield* (yield* host.credentials("cursor", "personal")).set(
95 new TextEncoder().encode("damaged credential"),
96 );
97 assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load()));
98 yield* Effect.tryPromise(() => restored.store.save(credentials));
99 yield* Effect.tryPromise(() => restored.store.clear());
100 assert.isUndefined(yield* Effect.tryPromise(() => personal.store.load()));
101 assert.strictEqual(
102 (yield* Effect.tryPromise(() => work.store.load()))?.apiKey,
103 "test-only-work-key",
104 );
105 }).pipe(Effect.provide(layerHost)),
106);
packages/provider-cursor/src/server/credentialStore.ts created+91
...@@ -0,0 +1,91 @@
1import type { SdkCredentialStore } from "@cursor/sdk";
2import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
9
10const Credentials = Schema.fromJsonString(
11 Schema.Struct({
12 version: Schema.Literal(1),
13 backendUrl: Schema.String,
14 apiKey: Schema.String,
15 createdAtMs: Schema.Finite,
16 apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite),
17 email: Schema.optionalKey(Schema.String),
18 }),
19);
20
21const decodeCredentials = Schema.decodeUnknownEffect(Credentials);
22const encodeCredentials = Schema.encodeEffect(Credentials);
23
24/**
25 * The SDK owns the credential format; persistence uses the environment's secret store.
26 * `legacyFile` is the SDK file store earlier versions used; its sign-in moves into
27 * the secret store once and the file is deleted.
28 */
29export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* (
30 instanceId: ProviderInstanceId,
31 legacyFile?: string,
32) {
33 const credentials = yield* (yield* ProviderHost.ProviderHost).credentials("cursor", instanceId);
34 if (legacyFile !== undefined) {
35 const fileSystem = yield* FileSystem.FileSystem;
36 yield* Effect.gen(function* () {
37 const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option);
38 if (Option.isNone(legacy)) return;
39 // A stored sign-in wins unless it is damaged; the SDK would ignore it anyway.
40 const stored = yield* credentials.get;
41 const storedIsValid =
42 Option.isSome(stored) &&
43 Option.isSome(
44 yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe(Effect.option),
45 );
46 if (
47 !storedIsValid &&
48 Option.isSome(yield* decodeCredentials(legacy.value).pipe(Effect.option))
49 ) {
50 yield* credentials.set(new TextEncoder().encode(legacy.value));
51 }
52 yield* fileSystem.remove(legacyFile);
53 }).pipe(
54 Effect.catch((error) =>
55 Effect.logWarning("Could not migrate legacy Cursor credentials", error),
56 ),
57 );
58 }
59 const store: SdkCredentialStore = {
60 load: () =>
61 Effect.runPromise(
62 credentials.get.pipe(
63 Effect.flatMap(
64 Option.match({
65 onNone: () => Effect.succeed(undefined),
66 onSome: (bytes) =>
67 decodeCredentials(new TextDecoder().decode(bytes)).pipe(
68 Effect.orElseSucceed(() => undefined),
69 ),
70 }),
71 ),
72 ),
73 ),
74 save: (value) =>
75 Effect.runPromise(
76 encodeCredentials(value).pipe(
77 Effect.mapError(
78 () =>
79 new ProviderSetupError({
80 instanceId,
81 operation: "credentials",
82 detail: "Cursor returned an unsupported credential format.",
83 }),
84 ),
85 Effect.flatMap((encoded) => credentials.set(new TextEncoder().encode(encoded))),
86 ),
87 ),
88 clear: () => Effect.runPromise(credentials.remove),
89 };
90 return { store, binding: credentials.binding };
91});
packages/provider-cursor/src/server/driver.test.ts created+169
...@@ -0,0 +1,169 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Stream from "effect/Stream";
9import { vi } from "vite-plus/test";
10import { HttpClient } from "effect/http";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
14import { layerTestProviderHost } from "@t3tools/provider-testing/host";
15import { CursorDriver } from "./driver.ts";
16import * as CursorAgentSdk from "./CursorAgentSdk.ts";
17import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
18import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
19import { Cursor } from "./sdk.ts";
20
21const layerTest = layerTestProviderHost({ runBackgroundWork: false }).pipe(
22 Layer.provideMerge(NodeServices.layer),
23 Layer.provideMerge(IdAllocator.layer),
24 Layer.provideMerge(
25 Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({
26 open: () => Effect.die("Maintenance resolution must not open a Cursor session"),
27 }),
28 ),
29 Layer.provideMerge(
30 Layer.succeed(
31 ProviderEventLoggers.ProviderEventLoggers,
32 ProviderEventLoggers.NoOpProviderEventLoggers,
33 ),
34 ),
35 Layer.provideMerge(
36 Layer.succeed(
37 HttpClient.HttpClient,
38 HttpClient.make(() => Effect.die("Disabled Cursor must not make an HTTP request")),
39 ),
40 ),
41);
42
43it.layer(layerTest)("CursorDriver", (it) => {
44 it.effect(
45 "persists browser credentials, uses them for chat, and closes the SDK session on logout",
46 () =>
47 Effect.gen(function* () {
48 const login = vi.spyOn(Cursor.auth, "login").mockImplementation(async (options) => {
49 options?.onLoginUrl?.("https://cursor.com/loginDeepControl?challenge=test-only");
50 await options?.store?.save({
51 version: 1,
52 backendUrl: "https://api2.cursor.sh",
53 apiKey: "instance-browser-key",
54 createdAtMs: 0,
55 apiKeyExpiresAtMs: 4_000_000_000_000,
56 email: "cursor@example.com",
57 });
58 return { apiKey: "instance-browser-key", apiKeyExpiresAtMs: 4_000_000_000_000 };
59 });
60 const me = vi.spyOn(Cursor, "me").mockResolvedValue({
61 apiKeyName: "T3 Code",
62 createdAt: "2026-01-01T00:00:00.000Z",
63 userEmail: "cursor@example.com",
64 });
65 const models = vi
66 .spyOn(Cursor.models, "list")
67 .mockResolvedValue([{ id: "auto", displayName: "Auto" }]);
68 yield* Effect.addFinalizer(() =>
69 Effect.sync(() => {
70 login.mockRestore();
71 me.mockRestore();
72 models.mockRestore();
73 }),
74 );
75 const input = {
76 instanceId: ProviderInstanceId.make("cursor-browser-persisted"),
77 displayName: "Personal Cursor",
78 enabled: true,
79 environment: [{ name: "CURSOR_API_KEY", value: "", sensitive: true }],
80 config: CursorDriver.defaultConfig(),
81 };
82 const openedKeys: Array<string | undefined> = [];
83 let closed = 0;
84 const instance = yield* CursorDriver.create(input).pipe(
85 Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
86 assertComplete: Effect.void,
87 open: (request) =>
88 Effect.sync(() => {
89 openedKeys.push(request.options.apiKey);
90 return {
91 agentId: "browser-auth-agent",
92 listMessages: Effect.succeed([]),
93 send: () => Effect.die("This test only opens a session"),
94 close: Effect.sync(() => {
95 closed += 1;
96 }),
97 };
98 }),
99 }),
100 );
101 expect((yield* instance.snapshot.refresh).auth.status).toBe("unauthenticated");
102 yield* instance.auth!.start("client");
103 yield* instance.auth!.subscribe("client").pipe(
104 Stream.filter((state) => state.phase === "succeeded"),
105 Stream.runHead,
106 Effect.map(Option.getOrThrow),
107 );
108 expect(me).toHaveBeenCalledWith({ apiKey: "instance-browser-key" });
109 expect(yield* instance.snapshot.getSnapshot).toMatchObject({
110 status: "ready",
111 auth: {
112 status: "authenticated",
113 type: "browser",
114 canLogout: true,
115 email: "cursor@example.com",
116 },
117 setup: { canAuthenticate: true, canInstall: false },
118 });
119 const threadId = ThreadId.make("cursor-browser-thread");
120 const modelSelection = { instanceId: input.instanceId, model: "auto" };
121 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
122 runtimeMode: "full-access",
123 interactionMode: "default",
124 cwd: process.cwd(),
125 });
126 const runtime = yield* instance.orchestrationAdapter.openSession({
127 threadId,
128 providerSessionId: ProviderSessionId.make("cursor-browser-session"),
129 modelSelection,
130 runtimePolicy,
131 });
132 yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy });
133 expect(openedKeys).toEqual(["instance-browser-key"]);
134 expect(closed).toBe(0);
135 const recreated = yield* CursorDriver.create(input);
136 expect((yield* recreated.snapshot.refresh).auth.status).toBe("authenticated");
137 yield* instance.auth!.logout(Effect.void);
138 expect(closed).toBe(1);
139 expect((yield* instance.snapshot.getSnapshot).auth.status).toBe("unauthenticated");
140 expect((yield* recreated.snapshot.refresh).auth.status).toBe("unauthenticated");
141 expect(
142 (yield* Effect.exit(runtime.ensureThread({ threadId, modelSelection, runtimePolicy })))
143 ._tag,
144 ).toBe("Failure");
145 expect(openedKeys).toEqual(["instance-browser-key"]);
146 expect(closed).toBe(1);
147 }).pipe(Effect.scoped),
148 );
149
150 it.effect("keeps the bundled SDK manual-only without probing or updating cursor-agent", () =>
151 Effect.gen(function* () {
152 const instance = yield* CursorDriver.create({
153 instanceId: ProviderInstanceId.make("cursor-sdk"),
154 displayName: "Cursor test",
155 enabled: false,
156 environment: [],
157 config: CursorDriver.defaultConfig(),
158 });
159 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
160 expect((yield* instance.snapshot.refresh).status).toBe("disabled");
161 }).pipe(
162 Effect.provideService(
163 ChildProcessSpawner.ChildProcessSpawner,
164 ChildProcessSpawner.make(() => Effect.die("SDK maintenance must not spawn a process")),
165 ),
166 Effect.scoped,
167 ),
168 );
169});
packages/provider-cursor/src/server/driver.ts created+279
...@@ -0,0 +1,279 @@
1/**
2 * CursorDriver — `ProviderDriver` for the Cursor Agent SDK runtime.
3 *
4 * Provider status, model discovery, orchestration, and text generation use the
5 * official Cursor SDK with an instance browser login or CURSOR_API_KEY.
6 *
7 * @module provider/Drivers/CursorDriver
8 */
9import { ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
10import { CursorSettings } from "../settings.ts";
11import * as Effect from "effect/Effect";
12import * as Crypto from "effect/Crypto";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
16import { HttpClient } from "effect/http";
17import { readCursorUsageLimits } from "./usageLimits.ts";
18
19import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
20import { makeCursorTextGeneration } from "./textGeneration.ts";
21import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./adapter.ts";
22import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
23import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus } from "./status.ts";
24import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
25import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
26import {
27 defaultProviderContinuationIdentity,
28 type ProviderDriver,
29 type ProviderInstance,
30} from "@t3tools/provider-core/server/driver";
31import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
32import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
33import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver";
34import {
35 haveProviderSnapshotSettingsChanged,
36 makeProviderSnapshotSettingsSource,
37 type ProviderSnapshotSettings,
38} from "@t3tools/provider-core/server/snapshotSettings";
39import { probeCursorSkills } from "./skills.ts";
40import { makeCursorAuth } from "./auth.ts";
41import * as CursorCredentialStore from "./credentialStore.ts";
42import * as CursorAgentSdk from "./CursorAgentSdk.ts";
43const decodeCursorSettings = Schema.decodeSync(CursorSettings);
44const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError);
45
46const DRIVER_KIND = ProviderDriverKind.make("cursor");
47const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({
48 provider: DRIVER_KIND,
49 packageName: null,
50});
51
52export type CursorDriverEnv =
53 | CursorAdapterV2DriverEnv
54 | Crypto.Crypto
55 | FileSystem.FileSystem
56 | Path.Path
57 | HttpClient.HttpClient
58 | ProviderHost.ProviderHost;
59
60export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
61 driverKind: DRIVER_KIND,
62 metadata: {
63 displayName: "Cursor",
64 supportsMultipleInstances: true,
65 },
66 configSchema: CursorSettings,
67 defaultConfig: (): CursorSettings => decodeCursorSettings({}),
68 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
69 Effect.gen(function* () {
70 const host = yield* ProviderHost.ProviderHost;
71 const fileSystem = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const httpClient = yield* HttpClient.HttpClient;
74 const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner;
75 const processEnv = mergeProviderInstanceEnvironment(environment);
76 const continuationIdentity = defaultProviderContinuationIdentity({
77 driverKind: DRIVER_KIND,
78 instanceId,
79 });
80 const stampIdentity = withInstanceIdentity({
81 instanceId,
82 driverKind: DRIVER_KIND,
83 displayName: displayName ?? "Cursor",
84 accentColor,
85 continuationGroupKey: continuationIdentity.continuationKey,
86 });
87 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;
88 const credentials = yield* CursorCredentialStore.makeCursorCredentialStore(
89 instanceId,
90 path.join(
91 host.paths.stateDir,
92 "provider-auth",
93 encodeURIComponent(instanceId),
94 "cursor.json",
95 ),
96 ).pipe(
97 Effect.mapError(
98 (cause) =>
99 new ProviderDriverError({
100 driver: DRIVER_KIND,
101 instanceId,
102 detail: "Could not open the Cursor credential store.",
103 cause,
104 }),
105 ),
106 );
107 const auth = yield* makeCursorAuth({
108 instanceId,
109 displayName: displayName ?? "Cursor",
110 enabled,
111 ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}),
112 store: credentials.store,
113 credentialBinding: credentials.binding,
114 onChanged: (signedIn): Effect.Effect<void, ProviderSetupError> =>
115 snapshot.refresh.pipe(
116 Effect.flatMap((provider) =>
117 !signedIn || provider.auth.status === "authenticated"
118 ? Effect.void
119 : Effect.fail(
120 new ProviderSetupError({
121 instanceId,
122 operation: "start",
123 detail: provider.message ?? "Could not verify the Cursor sign-in. Try again.",
124 }),
125 ),
126 ),
127 ),
128 });
129 const stampSnapshot: typeof stampIdentity = (draft) =>
130 stampIdentity({
131 ...draft,
132 setup: { canAuthenticate: !auth.usesApiKey, canInstall: false },
133 auth: {
134 ...draft.auth,
135 canLogout: !auth.usesApiKey,
136 },
137 });
138
139 const orchestrationAdapter = yield* CursorAdapterV2Driver.create({
140 instanceId,
141 displayName,
142 accentColor,
143 environment,
144 enabled,
145 config,
146 }).pipe(
147 Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
148 ...sdkRunner,
149 open: (input) =>
150 auth.requireApiKey.pipe(
151 Effect.flatMap((apiKey) =>
152 Effect.acquireRelease(
153 sdkRunner
154 .open({ ...input, options: { ...input.options, apiKey } })
155 .pipe(
156 Effect.flatMap((session) =>
157 Effect.cached(session.close).pipe(
158 Effect.map((close) => ({ ...session, close })),
159 ),
160 ),
161 ),
162 (session) => session.close.pipe(Effect.ignore),
163 ),
164 ),
165 auth.withAccess,
166 Effect.mapError((cause) =>
167 isSdkRunnerError(cause)
168 ? cause
169 : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }),
170 ),
171 ),
172 }),
173 Effect.mapError(
174 (cause) =>
175 new ProviderDriverError({
176 driver: DRIVER_KIND,
177 instanceId,
178 detail: "Failed to build Cursor orchestration adapter.",
179 cause,
180 }),
181 ),
182 );
183 const textGeneration = yield* makeCursorTextGeneration(
184 effectiveConfig,
185 processEnv,
186 auth.requireApiKey,
187 auth.withAccess,
188 );
189
190 const checkProvider = auth.readApiKey.pipe(
191 Effect.orElseSucceed(() => undefined),
192 Effect.flatMap((apiKey) =>
193 checkCursorProviderStatus(
194 effectiveConfig,
195 {
196 ...processEnv,
197 CURSOR_API_KEY: apiKey,
198 },
199 auth.usesApiKey ? "api-key" : "browser",
200 ).pipe(
201 Effect.flatMap((snapshot) =>
202 effectiveConfig.enabled &&
203 snapshot.installed &&
204 snapshot.auth.status === "authenticated"
205 ? host.settings.get.pipe(
206 Effect.flatMap((settings) =>
207 readCursorUsageLimits(
208 effectiveConfig,
209 { ...processEnv, CURSOR_API_KEY: apiKey },
210 settings.cursorKeychainUsageEnabled,
211 ),
212 ),
213 Effect.map((usageLimits) => ({ ...snapshot, usageLimits })),
214 )
215 : Effect.succeed(snapshot),
216 ),
217 ),
218 ),
219 Effect.provideService(HttpClient.HttpClient, httpClient),
220 Effect.provideService(FileSystem.FileSystem, fileSystem),
221 Effect.provideService(Path.Path, path),
222 Effect.map(stampSnapshot),
223 Effect.provide(CursorSdkCatalog.layer),
224 );
225
226 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
227 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({
228 resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES),
229 getSettings: snapshotSettings.getSettings,
230 streamSettings: snapshotSettings.streamSettings,
231 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
232 initialSnapshot: (settings) =>
233 buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampSnapshot)),
234 checkProvider,
235 }).pipe(
236 Effect.mapError(
237 (cause) =>
238 new ProviderDriverError({
239 driver: DRIVER_KIND,
240 instanceId,
241 detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`,
242 cause,
243 }),
244 ),
245 );
246
247 return {
248 instanceId,
249 driverKind: DRIVER_KIND,
250 continuationIdentity,
251 displayName,
252 accentColor,
253 enabled,
254 auth: auth.controller,
255 snapshot,
256 snapshotForCwd: (cwd) =>
257 !effectiveConfig.enabled
258 ? snapshot.getSnapshot
259 : Effect.all([
260 snapshot.getSnapshot,
261 probeCursorSkills(cwd, processEnv).pipe(
262 Effect.provideService(FileSystem.FileSystem, fileSystem),
263 Effect.provideService(Path.Path, path),
264 Effect.mapError(
265 (cause) =>
266 new ProviderDriverError({
267 driver: DRIVER_KIND,
268 instanceId,
269 detail: `Failed to discover Cursor skills for '${cwd}'`,
270 cause,
271 }),
272 ),
273 ),
274 ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))),
275 orchestrationAdapter,
276 textGeneration,
277 } satisfies ProviderInstance;
278 }),
279};
packages/provider-cursor/src/server/keychainToken.test.ts created+43
...@@ -0,0 +1,43 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { CursorKeychainTimeoutError, makeCachedCursorAccessTokenReader } from "./keychainToken.ts";
4
5describe("Cursor Keychain reader", () => {
6 it("shares concurrent reads and rechecks after the cache expires", async () => {
7 let reads = 0;
8 let time = 0;
9 const read = makeCachedCursorAccessTokenReader(
10 async () => {
11 reads++;
12 return `token-${reads}`;
13 },
14 () => time,
15 );
16 assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]);
17 assert.strictEqual(await read(), "token-1");
18 assert.strictEqual(reads, 1);
19 time = 5 * 60_000;
20 assert.strictEqual(await read(), "token-2");
21 });
22
23 it("gives up on an unanswered prompt and reuses it on the next read", async () => {
24 let reads = 0;
25 let allow: (token: string) => void = () => {};
26 const read = makeCachedCursorAccessTokenReader(
27 () => {
28 reads++;
29 return new Promise((resolve) => {
30 allow = resolve;
31 });
32 },
33 () => 0,
34 1,
35 );
36 const error = await read().catch((cause: unknown) => cause);
37 assert.instanceOf(error, CursorKeychainTimeoutError);
38 const retry = read();
39 allow("token");
40 assert.strictEqual(await retry, "token");
41 assert.strictEqual(reads, 1);
42 });
43});
packages/provider-cursor/src/server/keychainToken.ts created+55
...@@ -0,0 +1,55 @@
1import * as NodeModule from "node:module";
2
3const CACHE_MS = 5 * 60_000;
4
5const requireForKeyring = NodeModule.createRequire(import.meta.url);
6
7/** Rejected when nobody answers the macOS Keychain prompt in time. */
8export class CursorKeychainTimeoutError extends Error {
9 constructor() {
10 super("Timed out waiting for Keychain access.");
11 }
12}
13
14/**
15 * Share one Keychain request across usage history and limits in this server process.
16 *
17 * macOS shows the access prompt on the server's own screen, which a remote
18 * client cannot answer, so callers give up after `timeoutMs`. The read stays in
19 * flight: the next call reuses it instead of stacking a second prompt, and picks
20 * up the token once someone allows access.
21 */
22export function makeCachedCursorAccessTokenReader(
23 read: () => Promise<string | null>,
24 now: () => number = Date.now,
25 timeoutMs = 30_000,
26): () => Promise<string | null> {
27 let cached: { token: string; until: number } | null = null;
28 let pending: Promise<string | null> | null = null;
29 return () => {
30 if (cached && cached.until > now()) return Promise.resolve(cached.token);
31 pending ??= read()
32 .then((token) => {
33 cached = token ? { token, until: now() + CACHE_MS } : null;
34 return token;
35 })
36 .finally(() => {
37 pending = null;
38 });
39 const deadline = AbortSignal.timeout(timeoutMs);
40 return Promise.race([
41 pending,
42 new Promise<never>((_, reject) => {
43 deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), {
44 once: true,
45 });
46 }),
47 ]);
48 };
49}
50
51/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */
52export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => {
53 const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring");
54 return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null;
55});
packages/provider-cursor/src/server/sdk.test.ts created+193
...@@ -0,0 +1,193 @@
1// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- The probe is a plain Node child; this deadline kills it if it hangs.
2import * as NodeChildProcess from "node:child_process";
3import { describe, expect, it } from "vite-plus/test";
4
5const cursorSdkUrl = new URL("./sdk.ts", import.meta.url).href;
6
7// Loads sdk.ts in a fresh process. @cursor/sdk is stubbed so the guard
8// can be tested without the real package. The vitest worker already has its
9// own unhandledRejection listener, which would hide the process-exit behavior.
10const probeProgram = `
11import { mkdtempSync, writeFileSync } from "node:fs";
12import { tmpdir } from "node:os";
13import { join } from "node:path";
14import { registerHooks } from "node:module";
15import { pathToFileURL } from "node:url";
16import { spawn } from "node:child_process";
17
18const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-"));
19const stub = join(dir, "stub.cjs");
20writeFileSync(
21 stub,
22 "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };",
23);
24registerHooks({
25 resolve(specifier, context, next) {
26 if (specifier === "@cursor/sdk") {
27 return { url: pathToFileURL(stub).href, shortCircuit: true };
28 }
29 return next(specifier, context);
30 },
31});
32
33const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)});
34const mode = process.argv[1];
35const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid);
36
37if (mode === "predicate") {
38 const cursorShell = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
39 code: "ENOENT",
40 syscall: "spawn /bin/zsh",
41 path: "/bin/zsh",
42 spawnargs: ["-c", "dump_zsh_state >&4", "--", "true"],
43 });
44 const bashShell = Object.assign(new Error("spawn bash ENOENT"), {
45 code: "ENOENT",
46 syscall: "spawn bash",
47 spawnargs: ["-c", "dump_bash_state >&4"],
48 });
49 const sandboxRestore = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
50 code: "ENOENT",
51 syscall: "spawn /bin/zsh",
52 spawnargs: ["-c", "builtin eval \\"\${__CURSOR_SANDBOX_ENV_RESTORE:-}\\""],
53 });
54 const gitSpawn = Object.assign(new Error("spawn git ENOENT"), {
55 code: "ENOENT",
56 syscall: "spawn git",
57 path: "git",
58 spawnargs: ["status"],
59 });
60 const plainZsh = Object.assign(new Error("spawn /bin/zsh ENOENT"), {
61 code: "ENOENT",
62 syscall: "spawn /bin/zsh",
63 path: "/bin/zsh",
64 spawnargs: ["-lc", "true"],
65 });
66 console.log(
67 JSON.stringify({
68 cursorShell: isCursorShellSpawnFailure(cursorShell),
69 bashShell: isCursorShellSpawnFailure(bashShell),
70 sandboxRestore: isCursorShellSpawnFailure(sandboxRestore),
71 gitSpawn: isCursorShellSpawnFailure(gitSpawn),
72 plainZsh: isCursorShellSpawnFailure(plainZsh),
73 open: isCursorShellSpawnFailure(
74 Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }),
75 ),
76 plain: isCursorShellSpawnFailure(new Error("boom")),
77 string: isCursorShellSpawnFailure("spawn ENOENT"),
78 }),
79 );
80 process.exit(0);
81}
82
83if (mode === "cursor-shell") {
84 const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], {
85 cwd: missingCwd,
86 });
87 child.on("error", (error) => {
88 Promise.reject(error);
89 });
90 setTimeout(() => process.exit(0), 500);
91} else if (mode === "other-spawn") {
92 Promise.reject(
93 Object.assign(new Error("spawn git ENOENT"), {
94 code: "ENOENT",
95 syscall: "spawn git",
96 path: "git",
97 spawnargs: ["status"],
98 }),
99 );
100 setTimeout(() => process.exit(0), 500);
101} else if (mode === "other-rejection") {
102 Promise.reject(new Error("boom"));
103 setTimeout(() => process.exit(0), 500);
104} else if (mode === "spawn-without-listener") {
105 spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd });
106 setTimeout(() => process.exit(0), 500);
107} else {
108 console.error("Unknown cursor shell spawn guard probe: " + mode);
109 process.exit(2);
110}
111`;
112
113function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
114 return new Promise((resolve, reject) => {
115 const child = NodeChildProcess.spawn(
116 process.execPath,
117 [
118 "--experimental-strip-types",
119 "--disable-warning=ExperimentalWarning",
120 "--input-type=module",
121 "-e",
122 probeProgram,
123 mode,
124 ],
125 { stdio: ["ignore", "pipe", "pipe"] },
126 );
127 let stdout = "";
128 let stderr = "";
129 child.stdout.setEncoding("utf8");
130 child.stderr.setEncoding("utf8");
131 child.stdout.on("data", (chunk: string) => {
132 stdout += chunk;
133 });
134 child.stderr.on("data", (chunk: string) => {
135 stderr += chunk;
136 });
137 const timer = setTimeout(() => {
138 child.kill();
139 reject(new Error(`probe ${mode} timed out`));
140 }, 5_000);
141 child.on("error", reject);
142 child.on("exit", (code) => {
143 clearTimeout(timer);
144 resolve({ code, stdout, stderr });
145 });
146 });
147}
148
149describe("isCursorShellSpawnFailure", () => {
150 it("matches only Cursor's shell wrapper", async () => {
151 const result = await runProbe("predicate");
152 expect(result.code).toBe(0);
153 expect(JSON.parse(result.stdout)).toEqual({
154 cursorShell: true,
155 bashShell: true,
156 sandboxRestore: true,
157 gitSpawn: false,
158 plainZsh: false,
159 open: false,
160 plain: false,
161 string: false,
162 });
163 });
164});
165
166describe("Cursor shell spawn guard", () => {
167 it("keeps the process alive when Cursor's shell spawn rejects", async () => {
168 const result = await runProbe("cursor-shell");
169 expect(result.code).toBe(0);
170 expect(result.stderr).toContain("The server will keep running.");
171 expect(result.stderr).toContain("ENOENT");
172 });
173
174 it("still exits when a different spawn failure is unhandled", async () => {
175 const result = await runProbe("other-spawn");
176 expect(result.code).toBe(1);
177 expect(result.stderr).toContain("spawn git ENOENT");
178 expect(result.stderr).not.toContain("The server will keep running.");
179 });
180
181 it("still exits on unrelated unhandled rejections", async () => {
182 const result = await runProbe("other-rejection");
183 expect(result.code).toBe(1);
184 expect(result.stderr).toContain("boom");
185 });
186
187 it("leaves a spawn with no error listener fatal", async () => {
188 const result = await runProbe("spawn-without-listener");
189 expect(result.code).toBe(1);
190 expect(result.stderr).toContain("Unhandled");
191 expect(result.stderr).not.toContain("The server will keep running.");
192 });
193});
packages/provider-cursor/src/server/sdk.ts created+77
...@@ -0,0 +1,77 @@
1// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Installed before the SDK loads, outside an Effect runtime. stderr must match Node's default unhandled-rejection print.
2import * as NodeModule from "node:module";
3
4/**
5 * The Cursor Agent SDK runs in this process and spawns a shell for tool
6 * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`,
7 * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory
8 * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error,
9 * then rejects an internal promise nothing awaits. Node exits on that
10 * unhandled rejection and takes the server with it.
11 *
12 * This listener ignores only that rejection. Every other unhandled rejection
13 * still prints and exits when this is the process's only listener, matching
14 * Node's default. `child_process.spawn` itself is left alone, so git,
15 * terminals, and other providers keep their own error handling.
16 */
17const CURSOR_SHELL_SPAWN_MARKERS = [
18 "dump_zsh_state",
19 "dump_bash_state",
20 "__CURSOR_SANDBOX_ENV_RESTORE",
21] as const;
22
23export function isCursorShellSpawnFailure(reason: unknown): boolean {
24 if (typeof reason !== "object" || reason === null) {
25 return false;
26 }
27 const syscall = Reflect.get(reason, "syscall");
28 const code = Reflect.get(reason, "code");
29 const spawnargs = Reflect.get(reason, "spawnargs");
30 if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") {
31 return false;
32 }
33 if (!Array.isArray(spawnargs)) {
34 return false;
35 }
36 return spawnargs.some(
37 (arg) =>
38 typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)),
39 );
40}
41
42let cursorShellSpawnGuardInstalled = false;
43
44function installCursorShellSpawnGuard(): void {
45 if (cursorShellSpawnGuardInstalled) {
46 return;
47 }
48 cursorShellSpawnGuardInstalled = true;
49
50 process.on("unhandledRejection", (reason) => {
51 if (isCursorShellSpawnFailure(reason)) {
52 console.error("Cursor shell spawn failed. The server will keep running.", reason);
53 return;
54 }
55 // Another handler registered besides this one owns the decision.
56 if (process.listenerCount("unhandledRejection") > 1) {
57 return;
58 }
59 console.error(reason);
60 process.exit(1);
61 });
62}
63
64// Arm before the SDK loads. Its shell tools spawn from this process.
65installCursorShellSpawnGuard();
66
67// Cursor's Webpack chunks and local helpers must stay beside the SDK entry.
68// createRequire also loads that disk-backed package from a Node SEA executable.
69const requireCursorSdk = NodeModule.createRequire(import.meta.url);
70export const {
71 Agent,
72 AuthenticationError,
73 createAgentPlatform,
74 Cursor,
75 CursorSdkError,
76 InMemoryCredentialStore,
77} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk");
packages/provider-cursor/src/server/sdkModel.ts created+50
...@@ -0,0 +1,50 @@
1import type { ModelSelection as CursorSdkModelSelection, ModelParameterValue } from "@cursor/sdk";
2import type { ModelSelection } from "@t3tools/contracts";
3
4const CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION: Readonly<Record<string, string>> = {
5 context: "contextWindow",
6 fast: "fastMode",
7};
8
9const PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER: Readonly<Record<string, string>> = {
10 contextWindow: "context",
11 fastMode: "fast",
12};
13
14export function cursorSdkProviderOptionId(parameterId: string): string {
15 return CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION[parameterId] ?? parameterId;
16}
17
18function cursorSdkParameterId(providerOptionId: string): string {
19 return PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER[providerOptionId] ?? providerOptionId;
20}
21
22export function cursorSdkParameterPriority(parameterId: string): number {
23 switch (parameterId) {
24 case "effort":
25 case "reasoning":
26 return 0;
27 case "context":
28 return 1;
29 case "fast":
30 return 2;
31 case "thinking":
32 return 3;
33 default:
34 return 4;
35 }
36}
37
38export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection {
39 return {
40 id: modelSelection.model === "auto" ? "default" : modelSelection.model,
41 ...(modelSelection.options === undefined || modelSelection.options.length === 0
42 ? {}
43 : {
44 params: modelSelection.options.map((option): ModelParameterValue => ({
45 id: cursorSdkParameterId(option.id),
46 value: String(option.value),
47 })),
48 }),
49 };
50}
packages/provider-cursor/src/server/skills.test.ts created+186
...@@ -0,0 +1,186 @@
1import * as NodeOS from "node:os";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "vite-plus/test";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import type * as Scope from "effect/Scope";
8import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9
10import {
11 discoverCursorSkills,
12 hasCursorSkillMention,
13 probeCursorSkills,
14 rewriteCursorSkillMentions,
15} from "./skills.ts";
16
17const runNode = <A, E>(
18 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
19) => effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise);
20
21describe("Cursor skills", () => {
22 it("discovers recursive project skills with project precedence", async () =>
23 await runNode(
24 Effect.gen(function* () {
25 const fileSystem = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const userHome = yield* fileSystem
28 .makeTempDirectoryScoped({
29 directory: NodeOS.tmpdir(),
30 prefix: "cursor-skills-home-",
31 })
32 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
33 const workspace = yield* fileSystem
34 .makeTempDirectoryScoped({
35 directory: NodeOS.tmpdir(),
36 prefix: "cursor-skills-workspace-",
37 })
38 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
39 const writeSkill = Effect.fn("writeCursorSkill")(function* (
40 root: string,
41 name: string,
42 contents: string,
43 ) {
44 const skillDirectory = path.join(root, name);
45 yield* fileSystem.makeDirectory(skillDirectory, { recursive: true });
46 yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents);
47 });
48
49 yield* writeSkill(
50 path.join(userHome, ".cursor", "skills"),
51 "review",
52 "---\ndescription: user review\n---\n",
53 );
54 yield* writeSkill(
55 path.join(workspace, ".agents", "skills", "nested"),
56 "review",
57 "---\nname: Review changes\ndescription: project review\n---\n",
58 );
59 yield* writeSkill(
60 path.join(workspace, ".cursor", "skills"),
61 "internal",
62 "---\nuser-invocable: false\n---\n",
63 );
64 yield* writeSkill(
65 path.join(workspace, ".cursor", "skills"),
66 "oversized",
67 "x".repeat(1_000_001),
68 );
69 yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true });
70 yield* fileSystem.writeFileString(
71 path.join(userHome, ".codex", "skills"),
72 "not a directory",
73 );
74
75 const skills = yield* discoverCursorSkills(workspace, { HOME: userHome });
76 expect(skills).toEqual([
77 {
78 name: "internal",
79 path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"),
80 scope: "project",
81 enabled: true,
82 userInvocable: false,
83 },
84 {
85 name: "oversized",
86 path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"),
87 scope: "project",
88 enabled: true,
89 },
90 {
91 name: "review",
92 displayName: "Review changes",
93 description: "project review",
94 path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"),
95 scope: "project",
96 enabled: true,
97 },
98 ]);
99 expect(
100 (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag,
101 ).toBe("Failure");
102 }),
103 ));
104
105 it.skipIf(!symlinksSupported)(
106 "treats a symlinked skill outside the root as a package boundary",
107 async () =>
108 await runNode(
109 Effect.gen(function* () {
110 const fileSystem = yield* FileSystem.FileSystem;
111 const path = yield* Path.Path;
112 const userHome = yield* fileSystem.makeTempDirectoryScoped({
113 directory: NodeOS.tmpdir(),
114 prefix: "cursor-skills-home-",
115 });
116 const workspace = yield* fileSystem
117 .makeTempDirectoryScoped({
118 directory: NodeOS.tmpdir(),
119 prefix: "cursor-skills-workspace-",
120 })
121 .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory)));
122 const library = yield* fileSystem.makeTempDirectoryScoped({
123 directory: NodeOS.tmpdir(),
124 prefix: "cursor-skills-library-",
125 });
126 const writeSkill = Effect.fn("writeCursorSkill")(function* (
127 directory: string,
128 contents: string,
129 ) {
130 yield* fileSystem.makeDirectory(directory, { recursive: true });
131 yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents);
132 });
133
134 // A skill package managed in a config repo and installed by symlink.
135 // Its own SKILL.md must be discovered under the link name, but nothing
136 // below the target may be walked.
137 yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n");
138 yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n");
139 const root = path.join(workspace, ".cursor", "skills");
140 yield* fileSystem.makeDirectory(root, { recursive: true });
141 yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review"));
142
143 const skills = yield* discoverCursorSkills(workspace, { HOME: userHome });
144 expect(skills).toEqual([
145 {
146 name: "review",
147 description: "shared",
148 path: path.join(root, "review", "SKILL.md"),
149 scope: "project",
150 enabled: true,
151 },
152 ]);
153 expect(
154 (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag,
155 ).toBe("Success");
156 }),
157 ),
158 );
159
160 it("rewrites only discovered skill mentions into Cursor slash invocations", () => {
161 expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true);
162 expect(hasCursorSkillMention("please $review this")).toBe(true);
163 expect(
164 rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])),
165 ).toBe("use $review, keep $HOME and 5$review");
166 expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe(
167 "please /review this",
168 );
169 });
170 it("detects and invokes digit-leading Cursor skills without rewriting money", () => {
171 const names = new Set(["2spec", "20k", "100M", "1e6"]);
172 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
173 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
174 expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here");
175 expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here");
176 for (const text of [
177 "pay $20 tomorrow",
178 "budget $20k here",
179 "cost $100M total",
180 "limit $1e6 here",
181 ]) {
182 expect(hasCursorSkillMention(text)).toBe(false);
183 expect(rewriteCursorSkillMentions(text, names)).toBe(text);
184 }
185 });
186});
packages/provider-cursor/src/server/skills.ts created+290
...@@ -0,0 +1,290 @@
1/**
2 * CursorSkills — workspace-aware discovery and native invocation for Cursor.
3 *
4 * Cursor discovers Agent Skills recursively from user and project roots but
5 * its ACP command catalog only appears after opening a real session. Scanning
6 * the same roots avoids starting an agent and its MCP servers just to populate
7 * a composer menu.
8 *
9 * @module provider/Drivers/CursorSkills
10 */
11import * as NodeOS from "node:os";
12
13import type { ServerProviderSkill } from "@t3tools/contracts";
14import * as ByteSize from "effect/ByteSize";
15import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
17import * as Path from "effect/Path";
18import * as PlatformError from "effect/PlatformError";
19import * as Schema from "effect/Schema";
20import { parse as parseYamlDocument } from "yaml";
21
22const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
23const SKILL_MENTION_PATTERN =
24 /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;
25const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u");
26const MAX_SKILL_DEPTH = 10;
27const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000);
28const MAX_SKILL_SCAN_ENTRIES = 10_000;
29const MAX_SKILL_SCAN_BYTES = ByteSize.bytes(8_000_000);
30
31interface CursorSkillFrontmatter {
32 readonly description?: string;
33 readonly displayName?: string;
34 readonly userInvocationOnly?: boolean;
35 readonly userInvocable?: boolean;
36 readonly cliVisible: boolean;
37}
38
39interface CursorSkillScanBudget {
40 remainingEntries: number;
41 remainingBytes: bigint;
42 exhausted: boolean;
43 incomplete: boolean;
44}
45
46class CursorSkillsProbeError extends Schema.TaggedError<CursorSkillsProbeError>()(
47 "CursorSkillsProbeError",
48 {
49 reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]),
50 cwd: Schema.optional(Schema.String),
51 },
52) {
53 override get message(): string {
54 const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`;
55 return `Cursor skill discovery${location} was incomplete (${this.reason}).`;
56 }
57}
58
59const orUndefined = <A, R>(
60 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
61 budget?: CursorSkillScanBudget,
62): Effect.Effect<A | undefined, never, R> =>
63 effect.pipe(
64 Effect.map((value): A | undefined => value),
65 Effect.catchTags({
66 PlatformError: (error) => {
67 if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true;
68 return Effect.void.pipe(Effect.as(undefined));
69 },
70 }),
71 );
72
73function parseFrontmatterBoolean(value: unknown): boolean | undefined {
74 if (typeof value === "boolean") return value;
75 if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined;
76 if (typeof value !== "string") return undefined;
77 switch (value.trim().toLowerCase()) {
78 case "true":
79 case "yes":
80 case "on":
81 return true;
82 case "false":
83 case "no":
84 case "off":
85 return false;
86 default:
87 return undefined;
88 }
89}
90
91function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined {
92 const match = FRONTMATTER_PATTERN.exec(contents);
93 if (!match) return { cliVisible: true };
94
95 let parsed: unknown;
96 try {
97 parsed = parseYamlDocument(match[1] ?? "");
98 } catch {
99 return undefined;
100 }
101 if (typeof parsed !== "object" || parsed === null) return undefined;
102
103 const record = parsed as Record<string, unknown>;
104 const metadata =
105 typeof record.metadata === "object" && record.metadata !== null
106 ? (record.metadata as Record<string, unknown>)
107 : undefined;
108 const rawSurfaces = metadata?.surfaces;
109 const surfaces = Array.isArray(rawSurfaces)
110 ? rawSurfaces.filter((surface): surface is string => typeof surface === "string")
111 : typeof rawSurfaces === "string"
112 ? rawSurfaces.split(",")
113 : [];
114 const description = typeof record.description === "string" ? record.description.trim() : "";
115 const displayName = typeof record.name === "string" ? record.name.trim() : "";
116 return {
117 cliVisible:
118 surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"),
119 ...(description ? { description } : {}),
120 ...(displayName ? { displayName } : {}),
121 ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true
122 ? { userInvocationOnly: true }
123 : {}),
124 ...(parseFrontmatterBoolean(record["user-invocable"]) === false
125 ? { userInvocable: false }
126 : {}),
127 };
128}
129
130const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: {
131 readonly directory: string;
132 readonly scope: "user" | "project";
133 readonly budget: CursorSkillScanBudget;
134}): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
135 const fileSystem = yield* FileSystem.FileSystem;
136 const path = yield* Path.Path;
137 const skills: ServerProviderSkill[] = [];
138 if (input.budget.exhausted) return skills;
139 const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget);
140 if (!rootDirectory) return skills;
141 const visitedDirectories = new Set<string>();
142
143 const visit = Effect.fn("visitCursorSkillDirectory")(function* (
144 directory: string,
145 depth: number,
146 ): Effect.fn.Return<void, never> {
147 if (input.budget.exhausted) return;
148 const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget);
149 if (!resolvedDirectory) {
150 return;
151 }
152 if (visitedDirectories.has(resolvedDirectory)) {
153 return;
154 }
155 visitedDirectories.add(resolvedDirectory);
156 // A symlink whose target lives outside the root is a skill package
157 // boundary: read its own SKILL.md so linked skill libraries show up, but
158 // never walk the target tree.
159 const insideRoot =
160 resolvedDirectory === rootDirectory ||
161 resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`);
162
163 const skillPath = path.join(directory, "SKILL.md");
164 const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget);
165 if (skillInfo?.type === "File") {
166 let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true };
167 if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) {
168 const contents = yield* orUndefined(fileSystem.readFileString(skillPath));
169 if (contents !== undefined) {
170 input.budget.remainingBytes -= skillInfo.size;
171 frontmatter = parseSkillFrontmatter(contents);
172 }
173 }
174 const name = path.basename(directory).trim();
175 if (frontmatter?.cliVisible && name) {
176 skills.push({
177 name,
178 path: skillPath,
179 scope: input.scope,
180 enabled: true,
181 ...(frontmatter.displayName && frontmatter.displayName !== name
182 ? { displayName: frontmatter.displayName }
183 : {}),
184 ...(frontmatter.description ? { description: frontmatter.description } : {}),
185 ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}),
186 ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}),
187 });
188 }
189 }
190
191 if (!insideRoot) {
192 return;
193 }
194 const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget);
195 if (!entries) {
196 return;
197 }
198 for (const entry of [...entries].sort()) {
199 if (input.budget.remainingEntries === 0) {
200 input.budget.exhausted = true;
201 return;
202 }
203 input.budget.remainingEntries -= 1;
204 const child = path.join(directory, entry);
205 const info = yield* orUndefined(fileSystem.stat(child), input.budget);
206 if (info?.type !== "Directory") continue;
207 if (depth >= MAX_SKILL_DEPTH) {
208 input.budget.exhausted = true;
209 return;
210 }
211 yield* visit(child, depth + 1);
212 }
213 });
214
215 yield* visit(rootDirectory, 0);
216 return skills;
217});
218
219const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* (
220 cwd?: string,
221 environment: NodeJS.ProcessEnv = process.env,
222) {
223 const path = yield* Path.Path;
224 const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir();
225 const rootsBelow = (base: string, scope: "user" | "project") => [
226 { directory: path.join(base, ".cursor", "skills"), scope },
227 { directory: path.join(base, ".agents", "skills"), scope },
228 { directory: path.join(base, ".codex", "skills"), scope },
229 { directory: path.join(base, ".claude", "skills"), scope },
230 ];
231 const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")];
232
233 const skillsByName = new Map<string, ServerProviderSkill>();
234 const budget: CursorSkillScanBudget = {
235 remainingEntries: MAX_SKILL_SCAN_ENTRIES,
236 remainingBytes: MAX_SKILL_SCAN_BYTES,
237 exhausted: false,
238 incomplete: false,
239 };
240 for (const root of roots) {
241 if (budget.exhausted) break;
242 const skills = yield* discoverSkillsInRoot({ ...root, budget });
243 for (const skill of skills) {
244 if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill);
245 }
246 }
247 return {
248 skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)),
249 failureReason: budget.exhausted
250 ? ("scan-budget-exhausted" as const)
251 : budget.incomplete
252 ? ("filesystem-error" as const)
253 : undefined,
254 };
255});
256
257export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* (
258 cwd?: string,
259 environment: NodeJS.ProcessEnv = process.env,
260) {
261 return (yield* inspectCursorSkills(cwd, environment)).skills;
262});
263
264export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* (
265 cwd?: string,
266 environment: NodeJS.ProcessEnv = process.env,
267) {
268 const inspection = yield* inspectCursorSkills(cwd, environment);
269 if (inspection.failureReason) {
270 return yield* new CursorSkillsProbeError({
271 reason: inspection.failureReason,
272 ...(cwd ? { cwd } : {}),
273 });
274 }
275 return inspection.skills;
276});
277
278/** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */
279export function hasCursorSkillMention(prompt: string): boolean {
280 return HAS_SKILL_MENTION_PATTERN.test(prompt);
281}
282
283export function rewriteCursorSkillMentions(
284 prompt: string,
285 skillNames: ReadonlySet<string>,
286): string {
287 return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) =>
288 skillNames.has(name) ? `${prefix}/${name}` : match,
289 );
290}
packages/provider-cursor/src/server/status.test.ts created+252
...@@ -0,0 +1,252 @@
1import type { SDKModel } from "@cursor/sdk";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import type { CursorSettings } from "../settings.ts";
6import { CursorSettings as CursorSettingsSchema } from "../settings.ts";
7import { createModelCapabilities } from "@t3tools/shared/model";
8
9import {
10 buildCursorCapabilitiesFromSdkModel,
11 buildCursorDiscoveredModelsFromSdk,
12 buildCursorProviderSnapshot,
13 buildInitialCursorProviderSnapshot,
14 checkCursorProviderStatus,
15} from "./status.ts";
16import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
17
18const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema);
19
20function selectDescriptor(
21 id: string,
22 label: string,
23 options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>,
24) {
25 return {
26 id,
27 label,
28 type: "select" as const,
29 options: [...options],
30 ...(options.find((option) => option.isDefault)?.id
31 ? { currentValue: options.find((option) => option.isDefault)?.id }
32 : {}),
33 };
34}
35
36function booleanDescriptor(id: string, label: string, currentValue?: boolean) {
37 return {
38 id,
39 label,
40 type: "boolean" as const,
41 ...(typeof currentValue === "boolean" ? { currentValue } : {}),
42 };
43}
44
45const baseCursorSettings: CursorSettings = decodeCursorSettings({
46 enabled: true,
47 customModels: [],
48});
49
50const sdkParameterizedModel = {
51 id: "claude-opus-4-8",
52 displayName: "Opus 4.8",
53 parameters: [
54 {
55 id: "thinking",
56 displayName: "Thinking",
57 values: [{ value: "false" }, { value: "true" }],
58 },
59 {
60 id: "context",
61 displayName: "Context",
62 values: [
63 { value: "300k", displayName: "300K" },
64 { value: "1m", displayName: "1M" },
65 ],
66 },
67 {
68 id: "effort",
69 displayName: "Effort",
70 values: [
71 { value: "low", displayName: "Low" },
72 { value: "high", displayName: "High" },
73 ],
74 },
75 {
76 id: "fast",
77 displayName: "Fast",
78 values: [{ value: "false" }, { value: "true", displayName: "Fast" }],
79 },
80 ],
81 variants: [
82 {
83 displayName: "Opus 4.8",
84 isDefault: true,
85 params: [
86 { id: "thinking", value: "true" },
87 { id: "context", value: "1m" },
88 { id: "effort", value: "high" },
89 { id: "fast", value: "false" },
90 ],
91 },
92 ],
93} satisfies SDKModel;
94
95describe("buildInitialCursorProviderSnapshot", () => {
96 it.effect("uses SDK-specific pending status copy", () =>
97 Effect.gen(function* () {
98 const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings);
99
100 expect(provider).toMatchObject({
101 status: "warning",
102 message: "Checking Cursor SDK availability...",
103 });
104 }),
105 );
106});
107
108describe("buildCursorProviderSnapshot", () => {
109 it("downgrades ready status to warning when SDK model discovery returns no models", () => {
110 expect(
111 buildCursorProviderSnapshot({
112 checkedAt: "2026-01-01T00:00:00.000Z",
113 cursorSettings: baseCursorSettings,
114 parsed: {
115 version: null,
116 status: "ready",
117 auth: { status: "authenticated", type: "api-key", label: "Cursor API key" },
118 },
119 discoveryWarning: "Cursor SDK model discovery returned no built-in models.",
120 }),
121 ).toMatchObject({
122 status: "warning",
123 message: "Cursor SDK model discovery returned no built-in models.",
124 models: [],
125 supportsConversationRollback: false,
126 });
127 });
128});
129
130describe("Cursor SDK model discovery", () => {
131 it("maps native SDK parameter ids and default variant values to model capabilities", () => {
132 expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual(
133 createModelCapabilities({
134 optionDescriptors: [
135 selectDescriptor("effort", "Effort", [
136 { id: "low", label: "Low" },
137 { id: "high", label: "High", isDefault: true },
138 ]),
139 selectDescriptor("contextWindow", "Context", [
140 { id: "300k", label: "300K" },
141 { id: "1m", label: "1M", isDefault: true },
142 ]),
143 booleanDescriptor("fastMode", "Fast", false),
144 booleanDescriptor("thinking", "Thinking", true),
145 ],
146 }),
147 );
148 });
149
150 it("filters invalid and duplicate SDK model entries", () => {
151 expect(
152 buildCursorDiscoveredModelsFromSdk([
153 sdkParameterizedModel,
154 { ...sdkParameterizedModel, displayName: "Duplicate" },
155 { id: "", displayName: "Invalid" },
156 ]),
157 ).toEqual([
158 {
159 slug: "claude-opus-4-8",
160 name: "Opus 4.8",
161 isCustom: false,
162 capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel),
163 },
164 ]);
165 });
166});
167
168describe("checkCursorProviderStatus", () => {
169 it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () =>
170 Effect.gen(function* () {
171 const provider = yield* checkCursorProviderStatus(
172 {
173 ...baseCursorSettings,
174 customModels: ["internal/cursor-model"],
175 },
176 { CURSOR_API_KEY: "test-cursor-key" },
177 ).pipe(
178 Effect.provide(
179 CursorSdkCatalog.layerTest((apiKey) => {
180 expect(apiKey).toBe("test-cursor-key");
181 return Effect.succeed({
182 user: {
183 apiKeyName: "test-key",
184 userEmail: "cursor@example.com",
185 createdAt: "2026-01-01T00:00:00.000Z",
186 },
187 models: [sdkParameterizedModel],
188 });
189 }),
190 ),
191 );
192
193 expect(provider).toMatchObject({
194 status: "ready",
195 auth: {
196 status: "authenticated",
197 type: "api-key",
198 label: "Cursor API key (test-key)",
199 email: "cursor@example.com",
200 },
201 models: [
202 { slug: "claude-opus-4-8", isCustom: false },
203 { slug: "internal/cursor-model", isCustom: true },
204 ],
205 });
206 }),
207 );
208
209 it.effect("surfaces SDK authentication failures", () =>
210 Effect.gen(function* () {
211 const provider = yield* checkCursorProviderStatus(baseCursorSettings, {
212 CURSOR_API_KEY: "invalid-test-key",
213 }).pipe(
214 Effect.provide(
215 CursorSdkCatalog.layerTest(() =>
216 Effect.fail(
217 new CursorSdkCatalog.CursorSdkCatalogError({
218 authenticationFailure: true,
219 cause: new Error("unauthorized"),
220 }),
221 ),
222 ),
223 ),
224 );
225
226 expect(provider).toMatchObject({
227 status: "error",
228 auth: { status: "unauthenticated" },
229 message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.",
230 });
231 }),
232 );
233
234 it.effect("requires a Cursor API key without probing any external Cursor binary", () =>
235 Effect.gen(function* () {
236 const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe(
237 Effect.provide(
238 CursorSdkCatalog.layerTest(() =>
239 Effect.die("SDK catalog must not be used without CURSOR_API_KEY"),
240 ),
241 ),
242 );
243
244 expect(provider).toMatchObject({
245 installed: true,
246 status: "error",
247 auth: { status: "unauthenticated" },
248 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
249 });
250 }),
251 );
252});
packages/provider-cursor/src/server/status.ts created+350
...@@ -0,0 +1,350 @@
1import type { SDKModel, SDKUser } from "@cursor/sdk";
2import type {
3 ModelCapabilities,
4 ProviderOptionDescriptor,
5 ServerProviderAuth,
6 ServerProviderModel,
7 ServerProviderState,
8} from "@t3tools/contracts";
9import type { CursorSettings } from "../settings.ts";
10import { createModelCapabilities } from "@t3tools/shared/model";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Result from "effect/Result";
15
16import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./sdkModel.ts";
17import {
18 buildBooleanOptionDescriptor,
19 buildSelectOptionDescriptor,
20 buildServerProvider,
21 providerModelsFromSettings,
22 type ServerProviderDraft,
23} from "@t3tools/provider-core/server/snapshotProbe";
24import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
25
26const CURSOR_PRESENTATION = {
27 displayName: "Cursor",
28 supportsConversationRollback: false,
29 showInteractionModeToggle: true,
30} as const;
31const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
32 optionDescriptors: [],
33});
34
35const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000;
36
37export function buildInitialCursorProviderSnapshot(
38 cursorSettings: CursorSettings,
39): Effect.Effect<ServerProviderDraft> {
40 return Effect.gen(function* () {
41 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
42 const models = getCursorFallbackModels(cursorSettings);
43
44 if (!cursorSettings.enabled) {
45 return buildServerProvider({
46 presentation: CURSOR_PRESENTATION,
47 enabled: false,
48 checkedAt,
49 models,
50 probe: {
51 installed: false,
52 version: null,
53 status: "warning",
54 auth: { status: "unknown" },
55 message: "Cursor is disabled in T3 Code settings.",
56 },
57 });
58 }
59
60 return buildServerProvider({
61 presentation: CURSOR_PRESENTATION,
62 enabled: true,
63 checkedAt,
64 models,
65 probe: {
66 installed: true,
67 version: null,
68 status: "warning",
69 auth: { status: "unknown" },
70 message: "Checking Cursor SDK availability...",
71 },
72 });
73 });
74}
75
76function getCursorFallbackModels(
77 cursorSettings: Pick<CursorSettings, "customModels">,
78): ReadonlyArray<ServerProviderModel> {
79 return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES);
80}
81
82function toTitleCaseWords(value: string): string {
83 const parts: Array<string> = [];
84 for (const part of value.split(/[\s_-]+/g)) {
85 if (part.length > 0) {
86 parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase());
87 }
88 }
89 return parts.join(" ");
90}
91
92function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined {
93 return model.variants
94 ?.find((variant) => variant.isDefault)
95 ?.params.find((parameter) => parameter.id === parameterId)?.value;
96}
97
98export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities {
99 const seen = new Set<string>();
100 const optionDescriptors: Array<ProviderOptionDescriptor> = [];
101 const parameters = (model.parameters ?? [])
102 .map((parameter, index) => ({ parameter, index }))
103 .toSorted(
104 (left, right) =>
105 cursorSdkParameterPriority(left.parameter.id) -
106 cursorSdkParameterPriority(right.parameter.id) || left.index - right.index,
107 );
108 for (const { parameter } of parameters) {
109 const nativeId = parameter.id.trim();
110 const id = cursorSdkProviderOptionId(nativeId);
111 if (!nativeId || !id || seen.has(id)) {
112 continue;
113 }
114 seen.add(id);
115
116 const values = parameter.values.flatMap((entry) => {
117 const value = entry.value.trim();
118 if (!value) {
119 return [];
120 }
121 return [
122 {
123 value,
124 label: entry.displayName?.trim() || value,
125 },
126 ];
127 });
128 if (values.length === 0) {
129 continue;
130 }
131
132 const label = parameter.displayName?.trim() || toTitleCaseWords(id);
133 const defaultValue = cursorSdkDefaultParameterValue(model, nativeId);
134 const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase()));
135 if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) {
136 if (defaultValue === "true" || defaultValue === "false") {
137 optionDescriptors.push(
138 buildBooleanOptionDescriptor({
139 id,
140 label,
141 currentValue: defaultValue === "true",
142 }),
143 );
144 } else {
145 optionDescriptors.push(buildBooleanOptionDescriptor({ id, label }));
146 }
147 continue;
148 }
149
150 optionDescriptors.push(
151 buildSelectOptionDescriptor({
152 id,
153 label,
154 options: values.map((entry) => ({
155 ...entry,
156 ...(entry.value === defaultValue ? { isDefault: true } : {}),
157 })),
158 }),
159 );
160 }
161
162 return createModelCapabilities({ optionDescriptors });
163}
164
165export function buildCursorDiscoveredModelsFromSdk(
166 models: ReadonlyArray<SDKModel>,
167): ReadonlyArray<ServerProviderModel> {
168 const seen = new Set<string>();
169 return models.flatMap((model) => {
170 const slug = model.id.trim();
171 const name = model.displayName.trim();
172 if (!slug || !name || seen.has(slug)) {
173 return [];
174 }
175 seen.add(slug);
176 return [
177 {
178 slug,
179 name,
180 isCustom: false,
181 capabilities: buildCursorCapabilitiesFromSdkModel(model),
182 } satisfies ServerProviderModel,
183 ];
184 });
185}
186
187function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth {
188 const email = user.userEmail?.trim();
189 const apiKeyName = user.apiKeyName.trim();
190 return {
191 status: "authenticated",
192 type,
193 label:
194 type === "browser"
195 ? "Cursor account"
196 : apiKeyName
197 ? `Cursor API key (${apiKeyName})`
198 : "Cursor API key",
199 ...(email ? { email } : {}),
200 };
201}
202
203interface CursorProviderProbeResult {
204 readonly version: string | null;
205 readonly status: Exclude<ServerProviderState, "disabled">;
206 readonly auth: ServerProviderAuth;
207 readonly message?: string;
208}
209
210function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined {
211 const parts: Array<string> = [];
212 for (const message of messages) {
213 const trimmed = message?.trim();
214 if (trimmed) {
215 parts.push(trimmed);
216 }
217 }
218 return parts.length > 0 ? parts.join(" ") : undefined;
219}
220
221export function buildCursorProviderSnapshot(input: {
222 readonly checkedAt: string;
223 readonly cursorSettings: CursorSettings;
224 readonly parsed: CursorProviderProbeResult;
225 readonly discoveredModels?: ReadonlyArray<ServerProviderModel>;
226 readonly discoveryWarning?: string;
227}): ServerProviderDraft {
228 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
229 return buildServerProvider({
230 presentation: CURSOR_PRESENTATION,
231 enabled: input.cursorSettings.enabled,
232 checkedAt: input.checkedAt,
233 models: providerModelsFromSettings(
234 input.discoveredModels ?? [],
235 input.cursorSettings.customModels,
236 EMPTY_CAPABILITIES,
237 ),
238 probe: {
239 installed: true,
240 version: input.parsed.version,
241 status:
242 input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status,
243 auth: input.parsed.auth,
244 ...(message ? { message } : {}),
245 },
246 });
247}
248
249export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* (
250 cursorSettings: CursorSettings,
251 environment?: NodeJS.ProcessEnv,
252 authenticationType: "api-key" | "browser" = "api-key",
253): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> {
254 const checkedAt = DateTime.formatIso(yield* DateTime.now);
255 const fallbackModels = getCursorFallbackModels(cursorSettings);
256
257 if (!cursorSettings.enabled) {
258 return buildServerProvider({
259 presentation: CURSOR_PRESENTATION,
260 enabled: false,
261 checkedAt,
262 models: fallbackModels,
263 probe: {
264 installed: false,
265 version: null,
266 status: "warning",
267 auth: { status: "unknown" },
268 message: "Cursor is disabled in T3 Code settings.",
269 },
270 });
271 }
272
273 const sdkApiKey = environment?.CURSOR_API_KEY?.trim();
274 if (!sdkApiKey) {
275 return buildServerProvider({
276 presentation: CURSOR_PRESENTATION,
277 enabled: cursorSettings.enabled,
278 checkedAt,
279 models: fallbackModels,
280 probe: {
281 installed: true,
282 version: null,
283 status: "error",
284 auth: { status: "unauthenticated" },
285 message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
286 },
287 });
288 }
289
290 const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog;
291 const catalogResult = yield* sdkCatalog
292 .read(sdkApiKey)
293 .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result);
294
295 if (Result.isFailure(catalogResult)) {
296 yield* Effect.logWarning("Cursor SDK catalog probe failed", {
297 cause: catalogResult.failure.cause,
298 });
299 const authenticationFailure = catalogResult.failure.authenticationFailure;
300 return buildServerProvider({
301 presentation: CURSOR_PRESENTATION,
302 enabled: cursorSettings.enabled,
303 checkedAt,
304 models: fallbackModels,
305 probe: {
306 installed: true,
307 version: null,
308 status: "error",
309 auth: { status: authenticationFailure ? "unauthenticated" : "unknown" },
310 message: authenticationFailure
311 ? authenticationType === "browser"
312 ? "Cursor sign-in expired or was rejected. Sign in again in provider settings."
313 : "Cursor SDK authentication failed. Check CURSOR_API_KEY."
314 : "Cursor SDK catalog request failed. Check server logs for details.",
315 },
316 });
317 }
318
319 if (Option.isNone(catalogResult.success)) {
320 return buildServerProvider({
321 presentation: CURSOR_PRESENTATION,
322 enabled: cursorSettings.enabled,
323 checkedAt,
324 models: fallbackModels,
325 probe: {
326 installed: true,
327 version: null,
328 status: "error",
329 auth: { status: "unknown" },
330 message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`,
331 },
332 });
333 }
334
335 const snapshot = catalogResult.success.value;
336 const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models);
337 return buildCursorProviderSnapshot({
338 checkedAt,
339 cursorSettings,
340 parsed: {
341 version: null,
342 status: "ready",
343 auth: cursorSdkAuth(snapshot.user, authenticationType),
344 },
345 discoveredModels,
346 ...(discoveredModels.length === 0
347 ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." }
348 : {}),
349 });
350});
packages/provider-cursor/src/server/textGeneration.test.ts created+416
...@@ -0,0 +1,416 @@
1import type { RunResult } from "@cursor/sdk";
2import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
3import { CursorSettings } from "../settings.ts";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Fiber from "effect/Fiber";
8import * as TestClock from "effect/testing/TestClock";
9import * as Schema from "effect/Schema";
10import { createModelSelection } from "@t3tools/shared/model";
11import { beforeEach, vi } from "vite-plus/test";
12
13import { makeCursorTextGeneration } from "./textGeneration.ts";
14
15const cursorSdkMock = vi.hoisted(() => ({
16 create: vi.fn<(options: unknown) => Promise<unknown>>(),
17 send: vi.fn<(prompt: string) => Promise<unknown>>(),
18 close: vi.fn(),
19 cancel: vi.fn(async () => {}),
20 prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({
21 id: "run-cursor-text-generation-test",
22 status: "finished",
23 result:
24 '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}',
25 })),
26}));
27
28vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create } }));
29
30let hasCustomPolicy = false;
31const layerFs = FileSystem.layerNoop({
32 exists: () => Effect.succeed(hasCustomPolicy),
33 makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"),
34});
35
36const decodeCursorSettings = Schema.decodeSync(CursorSettings);
37const cursorSettings = decodeCursorSettings({ enabled: true });
38
39beforeEach(() => {
40 hasCustomPolicy = false;
41 cursorSdkMock.create.mockReset();
42 cursorSdkMock.send.mockReset();
43 cursorSdkMock.create.mockImplementation(async (options) => {
44 cursorSdkMock.send.mockImplementation(async (prompt) => ({
45 status: "running",
46 cancel: cursorSdkMock.cancel,
47 wait: () => cursorSdkMock.prompt(prompt, options),
48 }));
49 return {
50 close: cursorSdkMock.close,
51 [Symbol.asyncDispose]: async () => {
52 cursorSdkMock.close();
53 },
54 send: cursorSdkMock.send,
55 };
56 });
57 cursorSdkMock.close.mockClear();
58 cursorSdkMock.cancel.mockClear();
59 cursorSdkMock.prompt.mockReset();
60 cursorSdkMock.prompt.mockResolvedValue({
61 id: "run-cursor-text-generation-test",
62 status: "finished",
63 result:
64 '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}',
65 });
66});
67
68describe("CursorTextGeneration", () => {
69 it.effect("resolves the browser credential for every request after an account change", () =>
70 Effect.gen(function* () {
71 let apiKey = "first-browser-key";
72 const generation = yield* makeCursorTextGeneration(
73 cursorSettings,
74 {},
75 Effect.sync(() => apiKey),
76 );
77 const input = {
78 cwd: process.cwd(),
79 branch: "feature/cursor",
80 stagedSummary: "M file.ts",
81 stagedPatch: "diff",
82 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "auto"),
83 };
84 yield* generation.generateCommitMessage(input);
85 expect(cursorSdkMock.create).toHaveBeenLastCalledWith(
86 expect.objectContaining({ apiKey: "first-browser-key" }),
87 );
88 apiKey = "second-browser-key";
89 yield* generation.generateCommitMessage(input);
90 expect(cursorSdkMock.create).toHaveBeenLastCalledWith(
91 expect.objectContaining({ apiKey: "second-browser-key" }),
92 );
93 }).pipe(Effect.provide(layerFs)),
94 );
95
96 it.effect("uses the Cursor SDK prompt API with model parameters and API key", () =>
97 Effect.gen(function* () {
98 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
99 CURSOR_API_KEY: "test-cursor-key",
100 });
101
102 const generated = yield* textGeneration.generateCommitMessage({
103 cwd: process.cwd(),
104 branch: "feature/cursor-text-generation",
105 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
106 stagedPatch:
107 "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts",
108 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [
109 { id: "thinking", value: "high" },
110 { id: "contextWindow", value: "1m" },
111 { id: "fastMode", value: true },
112 ]),
113 });
114
115 expect(generated.subject).toBe("Add generated commit message");
116 expect(generated.body).toBe("- verify cursor sdk text generation");
117
118 expect(cursorSdkMock.prompt).toHaveBeenCalledTimes(1);
119 const [prompt, options] = (
120 cursorSdkMock.prompt.mock.calls as unknown as Array<[string, unknown]>
121 )[0]!;
122 expect(prompt).toContain("Staged patch:");
123 expect(options).toEqual({
124 apiKey: "test-cursor-key",
125 mode: "plan",
126 model: {
127 id: "gpt-5.4",
128 params: [
129 { id: "thinking", value: "high" },
130 { id: "context", value: "1m" },
131 { id: "fast", value: "true" },
132 ],
133 },
134 local: {
135 cwd: "/isolated-text-generation",
136 autoReview: false,
137 sandboxOptions: { enabled: true },
138 settingSources: [],
139 enableAgentRetries: true,
140 },
141 });
142 }).pipe(Effect.provide(layerFs)),
143 );
144
145 it.effect("continues in the temp directory when the SDK cannot sandbox", () =>
146 Effect.gen(function* () {
147 cursorSdkMock.create.mockImplementationOnce(async () => {
148 throw new Error(
149 "Local SDK sandboxing was requested, but sandboxing is not supported in this environment. Disable local.sandboxOptions.enabled or remove ~/.cursor/sandbox.json to run without sandboxing.",
150 );
151 });
152 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
153 CURSOR_API_KEY: "test-cursor-key",
154 });
155
156 const generated = yield* textGeneration.generateCommitMessage({
157 cwd: process.cwd(),
158 branch: "feature/cursor-text-generation",
159 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
160 stagedPatch: "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts",
161 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"),
162 });
163
164 expect(generated.subject).toBe("Add generated commit message");
165 expect(cursorSdkMock.create).toHaveBeenCalledTimes(2);
166 expect(cursorSdkMock.create.mock.calls[0]?.[0]).toMatchObject({
167 local: { sandboxOptions: { enabled: true } },
168 });
169 expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({
170 local: {
171 cwd: "/isolated-text-generation",
172 autoReview: false,
173 sandboxOptions: { enabled: false },
174 settingSources: [],
175 enableAgentRetries: true,
176 },
177 });
178 }).pipe(Effect.provide(layerFs)),
179 );
180
181 it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () =>
182 Effect.gen(function* () {
183 cursorSdkMock.create.mockImplementationOnce(async () => {
184 throw new Error("Cursor SDK network down");
185 });
186 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
187 CURSOR_API_KEY: "test-cursor-key",
188 });
189
190 const error = yield* Effect.flip(
191 textGeneration.generateCommitMessage({
192 cwd: process.cwd(),
193 branch: "feature/cursor-text-generation",
194 stagedSummary: "M README.md",
195 stagedPatch: "diff --git a/README.md b/README.md",
196 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"),
197 }),
198 );
199
200 expect(error.detail).toBe("Cursor SDK text generation failed.");
201 expect(cursorSdkMock.create).toHaveBeenCalledTimes(1);
202 }).pipe(Effect.provide(layerFs)),
203 );
204
205 it.effect("accepts json objects with extra assistant text around them", () =>
206 Effect.gen(function* () {
207 cursorSdkMock.prompt.mockResolvedValueOnce({
208 id: "run-cursor-text-generation-test",
209 status: "finished",
210 result:
211 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
212 });
213 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
214 CURSOR_API_KEY: "test-cursor-key",
215 });
216
217 const generated = yield* textGeneration.generateCommitMessage({
218 cwd: process.cwd(),
219 branch: "feature/cursor-noisy-json",
220 stagedSummary: "M README.md",
221 stagedPatch: "diff --git a/README.md b/README.md",
222 modelSelection: {
223 instanceId: ProviderInstanceId.make("cursor"),
224 model: "composer-2",
225 },
226 });
227
228 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
229 expect(generated.body).toBe("");
230 }).pipe(Effect.provide(layerFs)),
231 );
232
233 it.effect("generates thread titles through Cursor SDK text generation", () =>
234 Effect.gen(function* () {
235 cursorSdkMock.prompt.mockResolvedValueOnce({
236 id: "run-cursor-title-generation-test",
237 status: "finished",
238 result: '{"title":"\\"Trim reconnect spinner status after resume.\\""}',
239 });
240 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
241 CURSOR_API_KEY: "test-cursor-key",
242 });
243
244 const generated = yield* textGeneration.generateThreadTitle({
245 cwd: process.cwd(),
246 message: "Fix the reconnect spinner after a resumed session.",
247 modelSelection: {
248 instanceId: ProviderInstanceId.make("cursor"),
249 model: "composer-2",
250 },
251 });
252
253 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
254 }).pipe(Effect.provide(layerFs)),
255 );
256
257 it.effect.each(["error", "cancelled"] as const)(
258 "rejects a %s Cursor SDK run that includes valid title JSON",
259 (status) =>
260 Effect.gen(function* () {
261 const promptResult = {
262 id: "run-cursor-partial-title-test",
263 status,
264 result: '{"title":"Partial title from a failed run."}',
265 } satisfies RunResult;
266 cursorSdkMock.prompt.mockResolvedValueOnce(promptResult);
267 const generation = yield* makeCursorTextGeneration(cursorSettings, {
268 CURSOR_API_KEY: "test-cursor-key",
269 });
270 const failure = yield* Effect.flip(
271 generation.generateThreadTitle({
272 cwd: process.cwd(),
273 message: "Fix the reconnect spinner after a resumed session.",
274 modelSelection: {
275 instanceId: ProviderInstanceId.make("cursor"),
276 model: "composer-2",
277 },
278 }),
279 );
280 expect(failure).toBeInstanceOf(TextGenerationError);
281 expect(failure.operation).toBe("generateThreadTitle");
282 expect(failure.detail).toBe(
283 status === "cancelled"
284 ? "Cursor SDK request was cancelled."
285 : "Cursor SDK request finished with an error.",
286 );
287 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
288 }).pipe(Effect.provide(layerFs)),
289 );
290
291 it.effect("fails closed when ambient sandbox policy can expand write access", () =>
292 Effect.gen(function* () {
293 hasCustomPolicy = true;
294 const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" });
295 const failure = yield* Effect.flip(
296 generation.generateThreadTitle({
297 cwd: "/real-workspace",
298 message: "Title",
299 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
300 }),
301 );
302 expect(failure.detail).toContain("custom ~/.cursor/sandbox.json");
303 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();
304 }).pipe(Effect.provide(layerFs)),
305 );
306
307 it.effect("cancels the native run when text generation times out", () =>
308 Effect.gen(function* () {
309 let started!: () => void;
310 const called = new Promise<void>((resolve) => {
311 started = resolve;
312 });
313 cursorSdkMock.prompt.mockImplementationOnce(() => {
314 started();
315 return new Promise(() => {});
316 });
317 const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" });
318 const result = yield* generation
319 .generateThreadTitle({
320 cwd: "/real-workspace",
321 message: "Title",
322 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
323 })
324 .pipe(Effect.flip, Effect.forkScoped);
325 yield* Effect.promise(() => called);
326 yield* TestClock.adjust("180 seconds");
327 expect((yield* Fiber.join(result)).detail).toContain("timed out");
328 expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();
329 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
330 }).pipe(Effect.provide(layerFs), Effect.scoped),
331 );
332
333 it.effect.each(["create", "send"] as const)(
334 "times out pending %s and releases its late SDK resource",
335 (phase) =>
336 Effect.gen(function* () {
337 let started!: () => void;
338 const called = new Promise<void>((resolve) => {
339 started = resolve;
340 });
341 let resolveLate!: (resource: unknown) => void;
342 const pending = new Promise<unknown>((resolve) => {
343 resolveLate = resolve;
344 });
345 const wait = vi.fn();
346 const agent = {
347 close: cursorSdkMock.close,
348 [Symbol.asyncDispose]: async () => {
349 cursorSdkMock.close();
350 },
351 send: async () => {
352 started();
353 return pending;
354 },
355 };
356 cursorSdkMock.create.mockImplementation(async () => {
357 if (phase === "create") {
358 started();
359 return pending;
360 }
361 return agent;
362 });
363 const generation = yield* makeCursorTextGeneration(cursorSettings, {
364 CURSOR_API_KEY: "key",
365 });
366 const result = yield* generation
367 .generateThreadTitle({
368 cwd: "/real-workspace",
369 message: "Title",
370 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
371 })
372 .pipe(Effect.flip, Effect.forkScoped);
373 yield* Effect.promise(() => called);
374 yield* TestClock.adjust("180 seconds");
375 expect((yield* Fiber.join(result)).detail).toContain("timed out");
376 let cleanup!: () => void;
377 const cleaned = new Promise<void>((resolve) => {
378 cleanup = resolve;
379 });
380 if (phase === "create") cursorSdkMock.close.mockImplementationOnce(cleanup);
381 else
382 cursorSdkMock.cancel.mockImplementationOnce(async () => {
383 cleanup();
384 });
385 resolveLate(
386 phase === "create" ? agent : { status: "running", cancel: cursorSdkMock.cancel, wait },
387 );
388 yield* Effect.promise(() => cleaned);
389 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
390 expect(wait).not.toHaveBeenCalled();
391 if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce();
392 }).pipe(Effect.provide(layerFs), Effect.scoped),
393 );
394
395 it.effect("requires CURSOR_API_KEY before calling the SDK", () =>
396 Effect.gen(function* () {
397 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {});
398
399 const error = yield* Effect.flip(
400 textGeneration.generateCommitMessage({
401 cwd: process.cwd(),
402 branch: "feature/cursor-api-key",
403 stagedSummary: "M README.md",
404 stagedPatch: "diff --git a/README.md b/README.md",
405 modelSelection: {
406 instanceId: ProviderInstanceId.make("cursor"),
407 model: "composer-2",
408 },
409 }),
410 );
411
412 expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings.");
413 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();
414 }).pipe(Effect.provide(layerFs)),
415 );
416});
packages/provider-cursor/src/server/textGeneration.ts created+201
...@@ -0,0 +1,201 @@
1import * as NodeOS from "node:os";
2import * as FileSystem from "effect/FileSystem";
3
4import type { AgentOptions, RunResult } from "@cursor/sdk";
5import { Agent } from "./sdk.ts";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9
10import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts";
11import type { CursorSettings } from "../settings.ts";
12
13import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
14import { cursorSdkModelSelection } from "./sdkModel.ts";
15import type { CursorAuth } from "./auth.ts";
16
17const CURSOR_TIMEOUT_MS = 180_000;
18
19const isTextGenerationError = Schema.is(TextGenerationError);
20
21function cursorSdkResultDetail(result: RunResult): string {
22 switch (result.status) {
23 case "cancelled":
24 return "Cursor SDK request was cancelled.";
25 case "error":
26 return "Cursor SDK request finished with an error.";
27 case "finished":
28 return "Cursor SDK returned empty output.";
29 }
30}
31
32/**
33 * The SDK throws this when `sandboxOptions.enabled` is set and local sandboxing
34 * is unavailable. That happens on hosts that cannot launch `cursorsandbox`, and
35 * also after an unsandboxed run caches "unsupported" for the process.
36 */
37function cursorSandboxUnsupported(cause: unknown): boolean {
38 const seen = new Set<object>();
39 let current = cause;
40 while (typeof current === "object" && current !== null && !seen.has(current)) {
41 seen.add(current);
42 if (current instanceof Error && current.message.includes("sandboxing is not supported")) {
43 return true;
44 }
45 current = Reflect.get(current, "cause");
46 }
47 return false;
48}
49
50/**
51 * Build a Cursor text-generation closure bound to a specific `CursorSettings`
52 * payload. See `makeCodexAdapter` for the overall per-instance rationale.
53 */
54export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (
55 cursorSettings: CursorSettings,
56 environment?: NodeJS.ProcessEnv,
57 resolveApiKey?: Effect.Effect<string, ProviderSetupError>,
58 withAccess?: CursorAuth["withAccess"],
59) {
60 const fs = yield* FileSystem.FileSystem;
61 const resolvedEnvironment = environment ?? process.env;
62
63 const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>
64 Effect.gen(function* () {
65 if (!cursorSettings.enabled) {
66 return yield* new TextGenerationError({
67 operation,
68 detail: "Cursor is disabled in T3 Code settings.",
69 });
70 }
71
72 const apiKey = resolveApiKey
73 ? yield* resolveApiKey
74 : resolvedEnvironment.CURSOR_API_KEY?.trim();
75 if (!apiKey) {
76 return yield* new TextGenerationError({
77 operation,
78 detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.",
79 });
80 }
81
82 return apiKey;
83 });
84
85 // Ignores `cwd`: the agent runs in an empty temp directory, away from the project.
86 const runCursorJson: TextGenerationOperations.Runner = (input) => {
87 const { operation, prompt, modelSelection } = input;
88 return Effect.gen(function* () {
89 const apiKey = yield* resolveCursorApiKey(operation);
90 // The SDK loads sandbox.json independently of settingSources and lets it
91 // expand the writable paths. Its public API cannot override that policy.
92 if (yield* fs.exists(`${NodeOS.homedir()}/.cursor/sandbox.json`)) {
93 return yield* new TextGenerationError({
94 operation,
95 detail:
96 "Cursor text generation cannot enforce workspace isolation with a custom ~/.cursor/sandbox.json. Use another text-generation provider.",
97 });
98 }
99 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" });
100 const agentOptions = {
101 apiKey,
102 mode: "plan",
103 model: cursorSdkModelSelection(modelSelection),
104 local: {
105 cwd,
106 autoReview: false,
107 sandboxOptions: { enabled: true },
108 settingSources: [],
109 enableAgentRetries: true,
110 },
111 } satisfies AgentOptions;
112 const createCursorAgent = (sandboxEnabled: boolean) =>
113 Effect.tryPromise((signal) =>
114 Agent.create(
115 sandboxEnabled
116 ? agentOptions
117 : {
118 ...agentOptions,
119 local: {
120 ...agentOptions.local,
121 sandboxOptions: { enabled: false },
122 },
123 },
124 ).then((agent) => {
125 if (signal.aborted) agent.close();
126 return agent;
127 }),
128 );
129
130 const request = Effect.gen(function* () {
131 // Prefer the sandbox. When the SDK refuses it, the empty temp directory
132 // and empty setting sources still keep this run off the user's project.
133 const agent = yield* Effect.acquireRelease(
134 createCursorAgent(true).pipe(
135 Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)),
136 ),
137 (agent) =>
138 Effect.tryPromise(() => agent[Symbol.asyncDispose]()).pipe(
139 Effect.timeout("5 seconds"),
140 Effect.ignore({ log: true }),
141 ),
142 { interruptible: true },
143 );
144 const run = yield* Effect.tryPromise((signal) =>
145 agent.send(prompt).then((run) => {
146 if (signal.aborted) void run.cancel().catch(() => undefined);
147 return run;
148 }),
149 );
150 yield* Effect.addFinalizer(() =>
151 run.status === "running"
152 ? Effect.tryPromise(() => run.cancel()).pipe(
153 Effect.timeout("5 seconds"),
154 Effect.ignore({ log: true }),
155 )
156 : Effect.void,
157 );
158 return yield* Effect.tryPromise(() => run.wait());
159 }).pipe(Effect.scoped);
160 const promptResult = yield* request.pipe(
161 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
162 Effect.flatMap(
163 Option.match({
164 onNone: () =>
165 Effect.fail(
166 new TextGenerationError({
167 operation,
168 detail: "Cursor SDK request timed out.",
169 }),
170 ),
171 onSome: (value) => Effect.succeed(value),
172 }),
173 ),
174 );
175
176 const rawResult = promptResult.result?.trim() ?? "";
177 if (promptResult.status !== "finished" || !rawResult) {
178 return yield* new TextGenerationError({
179 operation,
180 detail: cursorSdkResultDetail(promptResult),
181 });
182 }
183
184 return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult);
185 }).pipe(
186 (effect) => (withAccess ? withAccess(effect) : effect),
187 Effect.scoped,
188 Effect.mapError((cause) =>
189 isTextGenerationError(cause)
190 ? cause
191 : new TextGenerationError({
192 operation,
193 detail: "Cursor SDK text generation failed.",
194 cause,
195 }),
196 ),
197 );
198 };
199
200 return TextGenerationOperations.fromRunner("CursorTextGeneration", runCursorJson);
201});
packages/provider-cursor/src/server/transportFailure.test.ts created+60
...@@ -0,0 +1,60 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { CursorTransportFailure } from "./transportFailure.ts";
4
5const diagnostic = "Error: RetriableError: WritableIterable is closed";
6
7function failureFor(chunks: string[]) {
8 const reply = new CursorTransportFailure();
9 for (const chunk of chunks) reply.push(chunk);
10 return reply.failure;
11}
12
13describe("CursorTransportFailure", () => {
14 it.each([
15 diagnostic,
16 "Error: ConnectError: [unavailable] transport closed",
17 "Error: ConnectError: [aborted] aborted",
18 "Error: ConnectError: [deadline_exceeded] timed out",
19 "Something went wrong communicating with the server. Please try again.",
20 ])("recognizes a terminal diagnostic: %s", (message) => {
21 expect(failureFor([message])).toBe(message);
22 expect(failureFor([...message])).toBe(message);
23 });
24
25 it("recognizes a standalone failure with a stack trace", () => {
26 expect(failureFor(["\n", diagnostic, "\n at send (cli.js:1:2)\n\n"])).toBe(diagnostic);
27 });
28
29 it.each([
30 "The error was " + diagnostic,
31 "This is an example of a transport error:\n" + diagnostic,
32 "I inspected the files.\n" + diagnostic,
33 "> " + diagnostic,
34 " " + diagnostic,
35 "```text\n" + diagnostic + "\n```",
36 "~~~\n" + diagnostic + "\n~~~",
37 diagnostic + "\nThis is an example of a transport error.",
38 "Error: ConnectError: [unauthenticated] sign in",
39 "Error: ConnectError: [permission_denied] subscription required",
40 "Error: HTTP 500 from the application being debugged",
41 "Error: RetriableError: [internal] Failed to run step, exceeded max retries",
42 "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)",
43 ])("preserves prose, code and non-transport errors: %s", (message) => {
44 expect(failureFor([...message])).toBeUndefined();
45 });
46
47 it("tracks fences across long lines without retaining the answer", () => {
48 expect(failureFor(["```\n", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
49 expect(failureFor(["```", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
50 expect(failureFor(["x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
51 });
52
53 it("does not retain a previous failure after new output", () => {
54 const reply = new CursorTransportFailure();
55 reply.push(diagnostic);
56 expect(reply.failure).toBe(diagnostic);
57 reply.push("\nRecovered successfully.");
58 expect(reply.failure).toBeUndefined();
59 });
60});
packages/provider-cursor/src/server/transportFailure.ts created+52
...@@ -0,0 +1,52 @@
1const maxLineLength = 4096;
2// Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics.
3const transportError =
4 /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/;
5const serverError = "Something went wrong communicating with the server. Please try again.";
6
7interface ReplyState {
8 disqualified: boolean;
9 failure: string | undefined;
10}
11
12function consumeLine(state: ReplyState, line: string) {
13 if (state.disqualified) return;
14 const text = line.trimEnd();
15 if (transportError.test(text) || text === serverError) {
16 state.failure = text;
17 } else if (text.trim() !== "" && !(state.failure && /^\s+at\s/.test(text))) {
18 // An explanation or code sample can quote the same diagnostic. Only
19 // classify an assistant item consisting entirely of a transport dump.
20 state.disqualified = true;
21 state.failure = undefined;
22 }
23}
24
25/** Tracks a standalone Cursor diagnostic without retaining an entire streamed answer. */
26export class CursorTransportFailure {
27 private state: ReplyState = { disqualified: false, failure: undefined };
28 private line = "";
29
30 push(text: string) {
31 for (const [index, part] of text.split("\n").entries()) {
32 if (this.state.disqualified) return;
33 if (index > 0) {
34 consumeLine(this.state, this.line);
35 this.line = "";
36 }
37 if (this.line.length + part.length > maxLineLength) {
38 this.state.disqualified = true;
39 this.state.failure = undefined;
40 this.line = "";
41 return;
42 }
43 this.line += part;
44 }
45 }
46
47 get failure() {
48 const state = { ...this.state };
49 consumeLine(state, this.line);
50 return state.failure;
51 }
52}
packages/provider-cursor/src/server/usageLimits.test.ts created+264
...@@ -0,0 +1,264 @@
1import { describe, expect, it } from "@effect/vitest";
2import { NodeServices } from "@effect/platform-node";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import { HttpClient, HttpClientResponse } from "effect/http";
8import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts";
9
10const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
11 effect.pipe(Effect.provide(NodeServices.layer));
12
13describe("Cursor usage limits", () => {
14 const checkedAt = "2026-09-16T00:00:00.000Z";
15
16 it("uses the advertised percentages and billing-cycle reset", () => {
17 const limits = cursorUsageResponseToLimits(
18 {
19 billingCycleEnd: "1789876386000",
20 planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 },
21 },
22 checkedAt,
23 );
24 expect(limits.windows).toEqual(
25 expect.arrayContaining([
26 {
27 id: "totalPercentUsed",
28 kind: "monthly",
29 label: "Overall",
30 usedPercent: 72.4,
31 resetsAt: "2026-09-20T03:53:06.000Z",
32 },
33 {
34 id: "autoPercentUsed",
35 kind: "monthly",
36 label: "Cursor Models",
37 usedPercent: 69.5,
38 resetsAt: "2026-09-20T03:53:06.000Z",
39 },
40 {
41 id: "apiPercentUsed",
42 kind: "monthly",
43 label: "Other Models",
44 usedPercent: 100,
45 resetsAt: "2026-09-20T03:53:06.000Z",
46 },
47 ]),
48 );
49 });
50
51 it("does not invent unused allowance for absent buckets", () => {
52 expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe(
53 "unsupported",
54 );
55 expect(
56 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows,
57 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]);
58 expect(
59 cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows,
60 ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]);
61 });
62
63 it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () =>
64 Effect.gen(function* () {
65 yield* withNodeServices(
66 Effect.gen(function* () {
67 const fs = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const directory = yield* fs.makeTempDirectoryScoped();
70 yield* fs.makeDirectory(path.join(directory, "cursor"));
71 yield* fs.writeFileString(
72 path.join(directory, "cursor", "auth.json"),
73 '{"accessToken":"instance-token"}',
74 );
75 const client = HttpClient.make((request) => {
76 expect(request.url).toBe(
77 "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
78 );
79 expect(request.method).toBe("POST");
80 expect(request.headers.authorization).toBe("Bearer instance-token");
81 expect(request.headers["connect-protocol-version"]).toBe("1");
82 return Effect.succeed(
83 HttpClientResponse.fromWeb(
84 request,
85 Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }),
86 ),
87 );
88 });
89 yield* fs.makeDirectory(path.join(directory, ".cursor"));
90 yield* fs.writeFileString(
91 path.join(directory, ".cursor", "auth.json"),
92 '{"accessToken":"instance-token"}',
93 );
94 for (const platform of ["linux", "darwin"] as const) {
95 const limits = yield* readCursorUsageLimits(
96 { apiEndpoint: "https://cursor.example/" },
97 { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" },
98 ).pipe(
99 Effect.provideService(HostProcessPlatform, platform),
100 Effect.provideService(HttpClient.HttpClient, client),
101 );
102 expect(limits.windows[0]?.usedPercent).toBe(42);
103 }
104 }).pipe(Effect.scoped),
105 );
106 }),
107 );
108
109 it.effect(
110 "never reads stale files for keychain or memory logins, but accepts an explicit auth token",
111 () =>
112 Effect.gen(function* () {
113 for (const platform of ["linux", "darwin"] as const) {
114 for (const token of [undefined, "explicit-token"]) {
115 const limits = yield* withNodeServices(
116 readCursorUsageLimits(
117 { apiEndpoint: "" },
118 {
119 AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default",
120 ...(token ? { CURSOR_AUTH_TOKEN: token } : {}),
121 },
122 false,
123 async () => {
124 throw new Error("must not read Keychain before opt-in");
125 },
126 ).pipe(
127 Effect.provideService(HostProcessPlatform, platform),
128 Effect.provideService(
129 FileSystem.FileSystem,
130 FileSystem.makeNoop({
131 readFileString: () => Effect.die("must not read an unrelated credential file"),
132 }),
133 ),
134 Effect.provideService(
135 HttpClient.HttpClient,
136 HttpClient.make((request) => {
137 expect(token).toBe("explicit-token");
138 expect(request.headers.authorization).toBe("Bearer explicit-token");
139 return Effect.succeed(
140 HttpClientResponse.fromWeb(
141 request,
142 Response.json({ planUsage: { totalPercentUsed: 10 } }),
143 ),
144 );
145 }),
146 ),
147 ),
148 );
149 if (token) expect(limits.windows[0]?.usedPercent).toBe(10);
150 else expect(limits.unavailable?.reason).toBe("unsupported");
151 }
152 }
153 }),
154 );
155
156 it.effect("reports failed requests without exposing credentials or response bodies", () =>
157 Effect.gen(function* () {
158 const limits = yield* withNodeServices(
159 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe(
160 Effect.provideService(
161 HttpClient.HttpClient,
162 HttpClient.make((request) =>
163 Effect.succeed(
164 HttpClientResponse.fromWeb(
165 request,
166 new Response("private response", { status: 401 }),
167 ),
168 ),
169 ),
170 ),
171 ),
172 );
173 expect(limits.unavailable).toEqual({
174 reason: "probeFailed",
175 message: "Cursor could not read usage limits.",
176 });
177 }),
178 );
179
180 it.effect("does not use a stored login for an explicit API key", () =>
181 Effect.gen(function* () {
182 const limits = yield* withNodeServices(
183 readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe(
184 Effect.provideService(
185 HttpClient.HttpClient,
186 HttpClient.make(() => Effect.die("must not request usage")),
187 ),
188 ),
189 );
190 expect(limits.unavailable?.reason).toBe("unsupported");
191 }),
192 );
193
194 it.effect("reads the default macOS Cursor login from Keychain for limits", () =>
195 Effect.gen(function* () {
196 const limits = yield* withNodeServices(
197 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe(
198 Effect.provideService(HostProcessPlatform, "darwin"),
199 Effect.provideService(
200 FileSystem.FileSystem,
201 FileSystem.makeNoop({
202 readFileString: () => Effect.die("must not read a stale credential file"),
203 }),
204 ),
205 Effect.provideService(
206 HttpClient.HttpClient,
207 HttpClient.make((request) => {
208 expect(request.headers.authorization).toBe("Bearer keychain-token");
209 return Effect.succeed(
210 HttpClientResponse.fromWeb(
211 request,
212 Response.json({ planUsage: { totalPercentUsed: 42 } }),
213 ),
214 );
215 }),
216 ),
217 ),
218 );
219 expect(limits.windows[0]?.usedPercent).toBe(42);
220 }),
221 );
222
223 it.effect("reports a Keychain initialization failure without failing the provider refresh", () =>
224 Effect.gen(function* () {
225 const limits = yield* withNodeServices(
226 readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => {
227 throw new Error("Keychain initialization failed");
228 }).pipe(
229 Effect.provideService(HostProcessPlatform, "darwin"),
230 Effect.provideService(
231 HttpClient.HttpClient,
232 HttpClient.make(() => Effect.die("must not request limits without a login")),
233 ),
234 ),
235 );
236 expect(limits.unavailable?.reason).toBe("probeFailed");
237 }),
238 );
239
240 it.effect("does not read Keychain or send its token to a custom endpoint", () =>
241 Effect.gen(function* () {
242 for (const [apiEndpoint, environment] of [
243 ["http://localhost:3000", {}],
244 ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }],
245 ["https://cursor-proxy.example", {}],
246 ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }],
247 ] as const) {
248 const limits = yield* withNodeServices(
249 readCursorUsageLimits({ apiEndpoint }, environment, true, async () => {
250 throw new Error("must not read Keychain for a custom endpoint");
251 }).pipe(
252 Effect.provideService(HostProcessPlatform, "darwin"),
253 Effect.provideService(
254 HttpClient.HttpClient,
255 HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")),
256 ),
257 ),
258 );
259 expect(limits.unavailable?.reason).toBe("unsupported");
260 expect(limits.unavailable?.message).toContain("default Cursor endpoint");
261 }
262 }),
263 );
264});
packages/provider-cursor/src/server/usageLimits.ts created+152
...@@ -0,0 +1,152 @@
1import * as NodeOS from "node:os";
2import type { ServerProviderUsageWindow } from "@t3tools/contracts";
3import type { CursorSettings } from "../settings.ts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
13import {
14 clampPercent,
15 makeUnavailableUsageLimits,
16 makeUsageLimits,
17} from "@t3tools/provider-core/server/usageLimits";
18import { readMacCursorAccessToken } from "./keychainToken.ts";
19
20const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
21const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh";
22const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));
23const CursorUsageResponse = Schema.Struct({
24 billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
25 planUsage: Schema.optional(
26 Schema.Struct({
27 totalPercentUsed: Schema.optional(Schema.Number),
28 autoPercentUsed: Schema.optional(Schema.Number),
29 apiPercentUsed: Schema.optional(Schema.Number),
30 }),
31 ),
32});
33
34/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */
35export function cursorUsageResponseToLimits(
36 response: typeof CursorUsageResponse.Type,
37 checkedAt: string,
38) {
39 const reset = DateTime.make(Number(response.billingCycleEnd));
40 const resetsAt =
41 Number(response.billingCycleEnd) > 0 && Option.isSome(reset)
42 ? DateTime.formatIso(reset.value)
43 : undefined;
44 const windows: ServerProviderUsageWindow[] = [];
45 if (response.planUsage) {
46 for (const { id, label } of CURSOR_USAGE_WINDOWS) {
47 const usedPercent = response.planUsage[id];
48 if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue;
49 windows.push({
50 id,
51 kind: "monthly",
52 label,
53 usedPercent: clampPercent(usedPercent),
54 ...(resetsAt ? { resetsAt } : {}),
55 });
56 }
57 }
58 return windows.length > 0
59 ? makeUsageLimits({ checkedAt, windows })
60 : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
61}
62
63export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* (
64 settings: Pick<CursorSettings, "apiEndpoint">,
65 environment: NodeJS.ProcessEnv = process.env,
66 allowKeychain = false,
67 keychainToken: () => Promise<string | null> = readMacCursorAccessToken,
68) {
69 const checkedAt = DateTime.formatIso(yield* DateTime.now);
70 return yield* Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const platform = yield* HostProcessPlatform;
74 const endpoint = (
75 settings.apiEndpoint?.trim() ||
76 environment.CURSOR_API_ENDPOINT?.trim() ||
77 DEFAULT_CURSOR_API_ENDPOINT
78 ).replace(/\/$/, "");
79 let token = environment.CURSOR_AUTH_TOKEN?.trim();
80 // An explicit API key can name a different account from the stored login.
81 if (!token && environment.CURSOR_API_KEY?.trim()) {
82 return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
83 }
84 const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE;
85 if (!token && credentialStore === "memory") {
86 return makeUnavailableUsageLimits({
87 checkedAt,
88 reason: "unsupported",
89 message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.",
90 });
91 }
92 if (!token && platform === "darwin" && credentialStore !== "file") {
93 if (!allowKeychain) {
94 return makeUnavailableUsageLimits({
95 checkedAt,
96 reason: "unsupported",
97 message: "Enable Cursor account usage in T3 Code to read its Keychain login.",
98 });
99 }
100 if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) {
101 return makeUnavailableUsageLimits({
102 checkedAt,
103 reason: "unsupported",
104 message: "Cursor account usage requires the default Cursor endpoint when using Keychain.",
105 });
106 }
107 token = (yield* Effect.tryPromise(keychainToken))?.trim();
108 } else if (!token) {
109 const home =
110 (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir();
111 const directory =
112 platform === "win32"
113 ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor")
114 : platform === "darwin"
115 ? path.join(home, ".cursor")
116 : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor");
117 const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe(
118 Effect.catchTags({
119 PlatformError: (error) =>
120 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
121 }),
122 Effect.flatMap(decodeCredentials),
123 );
124 token = credentials.accessToken?.trim();
125 }
126 if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
127 const client = yield* HttpClient.HttpClient;
128 const response = yield* client.execute(
129 HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe(
130 HttpClientRequest.bearerToken(token),
131 HttpClientRequest.setHeaders({
132 "connect-protocol-version": "1",
133 "x-cursor-client-type": "cli",
134 }),
135 HttpClientRequest.bodyJsonUnsafe({}),
136 ),
137 );
138 const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)(
139 yield* HttpClientResponse.filterStatusOk(response),
140 );
141 return cursorUsageResponseToLimits(body, checkedAt);
142 }).pipe(
143 Effect.timeout("10 seconds"),
144 Effect.orElseSucceed(() =>
145 makeUnavailableUsageLimits({
146 checkedAt,
147 reason: "probeFailed",
148 message: "Cursor could not read usage limits.",
149 }),
150 ),
151 );
152});
packages/provider-cursor/src/settings.ts created+35
...@@ -0,0 +1,35 @@
1/**
2 * Cursor instance settings. Shared by the server driver and the client
3 * settings form, so it holds only browser-safe schema code.
4 *
5 * @module provider-cursor/settings
6 */
7import { CustomModelSetting, makeProviderSettingsSchema, TrimmedString } from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";
10
11export const CursorSettings = makeProviderSettingsSchema(
12 {
13 // Off by default like Grok and OpenCode. Users opt in from Settings.
14 enabled: Schema.Boolean.pipe(
15 Schema.withDecodingDefault(Effect.succeed(false)),
16 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
17 ),
18 // Keep V1's CLI configuration when V2 rewrites the shared settings file.
19 // V2's Cursor SDK does not use these fields.
20 binaryPath: Schema.optionalKey(TrimmedString).pipe(
21 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
22 ),
23 apiEndpoint: Schema.optionalKey(TrimmedString).pipe(
24 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
25 ),
26 customModels: Schema.Array(CustomModelSetting).pipe(
27 Schema.withDecodingDefault(Effect.succeed([])),
28 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
29 ),
30 },
31 {
32 order: [],
33 },
34);
35export type CursorSettings = typeof CursorSettings.Type;
packages/provider-cursor/src/testing.ts created+13
...@@ -0,0 +1,13 @@
1/**
2 * Cursor internals the server's replay testkit, fixture recorder, and
3 * orchestration tests use directly.
4 *
5 * @module provider-cursor/testing
6 */
7export {
8 CURSOR_DEFAULT_INSTANCE_ID,
9 CURSOR_DRIVER_KIND,
10 CursorProviderCapabilitiesV2,
11 cursorSdkModelSelection,
12 makeCursorAgentOptions,
13} from "./server/adapter.ts";
packages/provider-cursor/tsconfig.json created+7
...@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
packages/provider-testing/src/host.ts+13
...@@ -10,6 +10,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";...@@ -10,6 +10,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
13import * as Path from "effect/Path";14import * as Path from "effect/Path";
14import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
1516
...@@ -39,6 +40,7 @@ export const layerTestProviderHost = (...@@ -39,6 +40,7 @@ export const layerTestProviderHost = (
39 yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie);40 yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie);
40 }41 }
41 const settings = options.settings ?? DEFAULT_SERVER_SETTINGS;42 const settings = options.settings ?? DEFAULT_SERVER_SETTINGS;
43 const credentials = new Map<string, Uint8Array>();
42 return ProviderHost.ProviderHost.of({44 return ProviderHost.ProviderHost.of({
43 paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir },45 paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir },
44 settings: {46 settings: {
...@@ -49,6 +51,17 @@ export const layerTestProviderHost = (...@@ -49,6 +51,17 @@ export const layerTestProviderHost = (
49 shouldRunBackgroundWork: () => Effect.succeed(options.runBackgroundWork ?? true),51 shouldRunBackgroundWork: () => Effect.succeed(options.runBackgroundWork ?? true),
50 // Tests store attachments flat under the attachments directory by id.52 // Tests store attachments flat under the attachments directory by id.
51 resolveAttachmentPath: (attachment) => path.join(attachmentsDir, attachment.id),53 resolveAttachmentPath: (attachment) => path.join(attachmentsDir, attachment.id),
54 // Credentials live in memory for the layer's lifetime.
55 credentials: (namespace, bindingId) =>
56 Effect.sync(() => {
57 const key = `${namespace}:${bindingId}`;
58 return {
59 binding: { owner: "t3" as const, key },
60 get: Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))),
61 set: (value: Uint8Array) => Effect.sync(() => void credentials.set(key, value)),
62 remove: Effect.sync(() => void credentials.delete(key)),
63 };
64 }),
52 });65 });
53 }),66 }),
54 );67 );
pnpm-lock.yaml+49
...@@ -356,6 +356,9 @@ importers:...@@ -356,6 +356,9 @@ importers:
356 '@t3tools/provider-core':356 '@t3tools/provider-core':
357 specifier: workspace:*357 specifier: workspace:*
358 version: link:../../packages/provider-core358 version: link:../../packages/provider-core
359 '@t3tools/provider-cursor':
360 specifier: workspace:*
361 version: link:../../packages/provider-cursor
359 '@t3tools/provider-muse':362 '@t3tools/provider-muse':
360 specifier: workspace:*363 specifier: workspace:*
361 version: link:../../packages/provider-muse364 version: link:../../packages/provider-muse
...@@ -600,6 +603,9 @@ importers:...@@ -600,6 +603,9 @@ importers:
600 '@t3tools/provider-core':603 '@t3tools/provider-core':
601 specifier: workspace:*604 specifier: workspace:*
602 version: link:../../packages/provider-core605 version: link:../../packages/provider-core
606 '@t3tools/provider-cursor':
607 specifier: workspace:*
608 version: link:../../packages/provider-cursor
603 '@t3tools/provider-muse':609 '@t3tools/provider-muse':
604 specifier: workspace:*610 specifier: workspace:*
605 version: link:../../packages/provider-muse611 version: link:../../packages/provider-muse
...@@ -736,6 +742,9 @@ importers:...@@ -736,6 +742,9 @@ importers:
736 '@t3tools/provider-core':742 '@t3tools/provider-core':
737 specifier: workspace:*743 specifier: workspace:*
738 version: link:../../packages/provider-core744 version: link:../../packages/provider-core
745 '@t3tools/provider-cursor':
746 specifier: workspace:*
747 version: link:../../packages/provider-cursor
739 '@t3tools/provider-muse':748 '@t3tools/provider-muse':
740 specifier: workspace:*749 specifier: workspace:*
741 version: link:../../packages/provider-muse750 version: link:../../packages/provider-muse
...@@ -1108,6 +1117,46 @@ importers:...@@ -1108,6 +1117,46 @@ importers:
1108 specifier: 1.0.01117 specifier: 1.0.0
1109 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)1118 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
11101119
1120 packages/provider-cursor:
1121 dependencies:
1122 '@cursor/sdk':
1123 specifier: 1.0.35
1124 version: 1.0.35
1125 '@napi-rs/keyring':
1126 specifier: ^1.3.0
1127 version: 1.3.0
1128 '@t3tools/contracts':
1129 specifier: workspace:*
1130 version: link:../contracts
1131 '@t3tools/provider-core':
1132 specifier: workspace:*
1133 version: link:../provider-core
1134 '@t3tools/shared':
1135 specifier: workspace:*
1136 version: link:../shared
1137 effect:
1138 specifier: 4.0.1
1139 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1140 yaml:
1141 specifier: ^2.9.0
1142 version: 2.9.0
1143 devDependencies:
1144 '@effect/platform-node':
1145 specifier: 4.0.1
1146 version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6)
1147 '@effect/vitest':
1148 specifier: 4.0.1
1149 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1150 '@t3tools/provider-testing':
1151 specifier: workspace:*
1152 version: link:../provider-testing
1153 '@types/node':
1154 specifier: 24.12.4
1155 version: 24.12.4
1156 vite-plus:
1157 specifier: 1.0.0
1158 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1159
1111 packages/provider-muse:1160 packages/provider-muse:
1112 dependencies:1161 dependencies:
1113 '@muse-code/sdk':1162 '@muse-code/sdk':
scripts/lib/cursor-sdk-packaging.test.ts+1-1
...@@ -79,7 +79,7 @@ it("loads packaged Cursor catalog chunks without credentials or checkout depende...@@ -79,7 +79,7 @@ it("loads packaged Cursor catalog chunks without credentials or checkout depende
79 entry,79 entry,
80 `80 `
81 import assert from 'node:assert/strict';81 import assert from 'node:assert/strict';
82 import { Cursor } from ${JSON.stringify(NodePath.join(repoRoot, "apps/server/src/provider/cursorSdk.ts"))};82 import { Cursor } from ${JSON.stringify(NodePath.join(repoRoot, "packages/provider-cursor/src/server/sdk.ts"))};
83 for (const [operation, request] of [83 for (const [operation, request] of [
84 ['Cursor.models.list', () => Cursor.models.list({ apiKey: '' })],84 ['Cursor.models.list', () => Cursor.models.list({ apiKey: '' })],
85 ['Cursor.me', () => Cursor.me({ apiKey: '' })],85 ['Cursor.me', () => Cursor.me({ apiKey: '' })],
scripts/release-smoke.ts+1
...@@ -31,6 +31,7 @@ const workspaceFiles = [...@@ -31,6 +31,7 @@ const workspaceFiles = [
31 "packages/provider-core/package.json",31 "packages/provider-core/package.json",
32 "packages/provider-testing/package.json",32 "packages/provider-testing/package.json",
33 "packages/provider-pi/package.json",33 "packages/provider-pi/package.json",
34 "packages/provider-cursor/package.json",
34 "packages/provider-muse/package.json",35 "packages/provider-muse/package.json",
35 "packages/provider-opencode/package.json",36 "packages/provider-opencode/package.json",
36 "packages/effect-acp/package.json",37 "packages/effect-acp/package.json",