authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 20:00:02-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
logbe308beacc92e7309ec7f00e7084e6ee954ad8ae
treea3d99d4947d1ff779c11bdf3bbb41517f76c44da
parent2589176c82fdddaa11429d13f38ee987a945566b

Remove Cursor adapter from stack base

Co-authored-by: codex <codex@users.noreply.github.com>

10 files changed, 4 insertions(+), 2505 deletions(-)

apps/server/src/provider/Layers/CursorAdapter.test.ts deleted-655
...@@ -1,655 +0,0 @@
1import { EventEmitter } from "node:events";
2import { PassThrough } from "node:stream";
3import readline from "node:readline";
4
5import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";
6import { assert, describe, it } from "@effect/vitest";
7import { Effect, Fiber, Stream } from "effect";
8
9import { ProviderAdapterValidationError } from "../Errors.ts";
10import { CursorAdapter } from "../Services/CursorAdapter.ts";
11import { makeCursorAdapterLive } from "./CursorAdapter.ts";
12
13const THREAD_ID = ThreadId.makeUnsafe("thread-cursor-1");
14const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-resume");
15const LEGACY_RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-legacy");
16
17class FakeCursorAcpProcess extends EventEmitter {
18 readonly stdin = new PassThrough();
19 readonly stdout = new PassThrough();
20 readonly stderr = new PassThrough();
21 readonly requests: Array<{ method: string; params: unknown }> = [];
22 killed = false;
23
24 private readonly input = readline.createInterface({ input: this.stdin });
25 private permissionRequestId = 700;
26 lastPermissionSelection: string | undefined;
27
28 constructor() {
29 super();
30 this.input.on("line", (line) => {
31 const message = JSON.parse(line) as Record<string, unknown>;
32 if (typeof message.method === "string") {
33 this.handleRequest(message);
34 return;
35 }
36
37 if (message.id === this.permissionRequestId) {
38 const optionId =
39 (message.result as { outcome?: { optionId?: unknown } } | undefined)?.outcome?.optionId;
40 if (typeof optionId === "string") {
41 this.lastPermissionSelection = optionId;
42 }
43 }
44 });
45 }
46
47 kill(): boolean {
48 if (this.killed) {
49 return true;
50 }
51 this.killed = true;
52 this.emit("exit", 0, null);
53 return true;
54 }
55
56 emitPermissionRequest(): void {
57 this.emitServerMessage({
58 jsonrpc: "2.0",
59 id: this.permissionRequestId,
60 method: "session/request_permission",
61 params: {
62 sessionId: "acp-session-1",
63 toolCall: {
64 toolCallId: "tool-perm-1",
65 kind: "execute",
66 title: "`pwd`",
67 },
68 options: [
69 { optionId: "allow-once", kind: "allow_once" },
70 { optionId: "allow-always", kind: "allow_always" },
71 { optionId: "reject-once", kind: "reject_once" },
72 ],
73 },
74 });
75 }
76
77 private handleRequest(message: Record<string, unknown>): void {
78 const method = message.method;
79 const id = message.id;
80 if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) {
81 return;
82 }
83 this.requests.push({ method, params: message.params });
84
85 switch (method) {
86 case "initialize": {
87 const protocolVersion = (message.params as { protocolVersion?: unknown } | undefined)
88 ?.protocolVersion;
89 if (typeof protocolVersion !== "number") {
90 this.emitServerMessage({
91 jsonrpc: "2.0",
92 id,
93 error: {
94 code: -32602,
95 message: "Invalid params",
96 data: {
97 _errors: [],
98 protocolVersion: {
99 _errors: ["Invalid input: expected number, received undefined"],
100 },
101 },
102 },
103 });
104 return;
105 }
106 this.emitServerMessage({
107 jsonrpc: "2.0",
108 id,
109 result: {
110 protocolVersion: 1,
111 agentCapabilities: {
112 loadSession: true,
113 },
114 authMethods: [{ id: "cursor_login" }],
115 },
116 });
117 return;
118 }
119 case "authenticate":
120 this.emitServerMessage({
121 jsonrpc: "2.0",
122 id,
123 result: {},
124 });
125 return;
126 case "session/new":
127 this.emitServerMessage({
128 jsonrpc: "2.0",
129 id,
130 result: {
131 sessionId: "acp-session-1",
132 modes: {
133 currentModeId: "agent",
134 availableModes: [{ id: "agent" }],
135 },
136 },
137 });
138 return;
139 case "session/load":
140 this.emitServerMessage({
141 jsonrpc: "2.0",
142 id,
143 result: {},
144 });
145 return;
146 case "session/set_model":
147 this.emitServerMessage({
148 jsonrpc: "2.0",
149 id,
150 result: {},
151 });
152 return;
153 case "session/prompt": {
154 this.emitServerMessage({
155 jsonrpc: "2.0",
156 method: "session/update",
157 params: {
158 sessionId: "acp-session-1",
159 update: {
160 sessionUpdate: "agent_thought_chunk",
161 content: {
162 type: "text",
163 text: "thinking",
164 },
165 },
166 },
167 });
168
169 this.emitServerMessage({
170 jsonrpc: "2.0",
171 method: "session/update",
172 params: {
173 sessionId: "acp-session-1",
174 update: {
175 sessionUpdate: "agent_message_chunk",
176 content: {
177 type: "text",
178 text: "hello",
179 },
180 },
181 },
182 });
183
184 this.emitServerMessage({
185 jsonrpc: "2.0",
186 method: "session/update",
187 params: {
188 sessionId: "acp-session-1",
189 update: {
190 sessionUpdate: "tool_call",
191 toolCallId: "tool-1",
192 kind: "execute",
193 title: "`pwd`",
194 rawInput: { command: "pwd" },
195 },
196 },
197 });
198
199 this.emitServerMessage({
200 jsonrpc: "2.0",
201 method: "session/update",
202 params: {
203 sessionId: "acp-session-1",
204 update: {
205 sessionUpdate: "tool_call_update",
206 toolCallId: "tool-1",
207 status: "completed",
208 rawOutput: {
209 exitCode: 0,
210 stdout: "/tmp/project",
211 stderr: "",
212 },
213 },
214 },
215 });
216
217 this.emitServerMessage({
218 jsonrpc: "2.0",
219 id,
220 result: {
221 stopReason: "end_turn",
222 },
223 });
224 return;
225 }
226 case "session/cancel":
227 this.emitServerMessage({
228 jsonrpc: "2.0",
229 id,
230 error: {
231 code: -32601,
232 message: "Method not found",
233 },
234 });
235 return;
236 default:
237 this.emitServerMessage({
238 jsonrpc: "2.0",
239 id,
240 error: {
241 code: -32601,
242 message: `Unhandled method: ${method}`,
243 },
244 });
245 }
246 }
247
248 private emitServerMessage(message: unknown): void {
249 this.stdout.write(`${JSON.stringify(message)}\n`);
250 }
251}
252
253describe("CursorAdapterLive", () => {
254 it.effect("returns validation error for non-cursor provider on startSession", () => {
255 const fake = new FakeCursorAcpProcess();
256 const layer = makeCursorAdapterLive({
257 createProcess: () => fake as never,
258 });
259
260 return Effect.gen(function* () {
261 const adapter = yield* CursorAdapter;
262 const result = yield* adapter
263 .startSession({
264 provider: "codex",
265 threadId: THREAD_ID,
266 runtimeMode: "full-access",
267 })
268 .pipe(Effect.result);
269
270 assert.equal(result._tag, "Failure");
271 if (result._tag !== "Failure") {
272 return;
273 }
274 assert.deepEqual(
275 result.failure,
276 new ProviderAdapterValidationError({
277 provider: "cursor",
278 operation: "startSession",
279 issue: "Expected provider 'cursor' but received 'codex'.",
280 }),
281 );
282 }).pipe(Effect.provide(layer));
283 });
284
285 it.effect("maps ACP prompt/update events into canonical runtime events", () => {
286 const fake = new FakeCursorAcpProcess();
287 const layer = makeCursorAdapterLive({
288 createProcess: () => fake as never,
289 });
290
291 return Effect.gen(function* () {
292 const adapter = yield* CursorAdapter;
293
294 const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe(
295 Stream.runCollect,
296 Effect.forkChild,
297 );
298
299 const session = yield* adapter.startSession({
300 provider: "cursor",
301 threadId: THREAD_ID,
302 cwd: "/tmp/project",
303 runtimeMode: "full-access",
304 });
305
306 const turn = yield* adapter.sendTurn({
307 threadId: session.threadId,
308 input: "hello",
309 attachments: [],
310 });
311
312 const events = Array.from(yield* Fiber.join(eventsFiber));
313 assert.deepEqual(
314 events.map((event) => event.type),
315 [
316 "session.configured",
317 "auth.status",
318 "auth.status",
319 "session.started",
320 "thread.started",
321 "session.state.changed",
322 "turn.started",
323 "content.delta",
324 "content.delta",
325 "item.started",
326 "item.completed",
327 "item.completed",
328 "turn.completed",
329 ],
330 );
331
332 const turnStarted = events[6];
333 assert.equal(turnStarted?.type, "turn.started");
334 if (turnStarted?.type === "turn.started") {
335 assert.equal(String(turnStarted.turnId), String(turn.turnId));
336 }
337
338 const completion = events[12];
339 assert.equal(completion?.type, "turn.completed");
340 if (completion?.type === "turn.completed") {
341 assert.equal(completion.payload.state, "completed");
342 }
343 }).pipe(Effect.provide(layer));
344 });
345
346 it.effect("passes requested model to ACP process startup", () => {
347 const fake = new FakeCursorAcpProcess();
348 let createProcessInput:
349 | {
350 readonly binaryPath: string;
351 readonly cwd: string;
352 readonly env: NodeJS.ProcessEnv;
353 readonly model?: string;
354 }
355 | undefined;
356 const layer = makeCursorAdapterLive({
357 createProcess: (input) => {
358 createProcessInput = input;
359 return fake as never;
360 },
361 });
362
363 return Effect.gen(function* () {
364 const adapter = yield* CursorAdapter;
365 yield* adapter.startSession({
366 provider: "cursor",
367 threadId: THREAD_ID,
368 model: "composer-1.5",
369 runtimeMode: "full-access",
370 });
371
372 assert.deepEqual(createProcessInput?.model, "composer-1.5");
373 }).pipe(Effect.provide(layer));
374 });
375
376 it.effect("writes provider-native observability records when enabled", () => {
377 const nativeEvents: Array<{
378 event?: {
379 provider?: string;
380 method?: string;
381 threadId?: string;
382 };
383 }> = [];
384 const fake = new FakeCursorAcpProcess();
385 const layer = makeCursorAdapterLive({
386 createProcess: () => fake as never,
387 nativeEventLogger: {
388 filePath: "memory://cursor-native-events",
389 write: (event) => {
390 nativeEvents.push(event as (typeof nativeEvents)[number]);
391 return Effect.void;
392 },
393 close: () => Effect.void,
394 },
395 });
396
397 return Effect.gen(function* () {
398 const adapter = yield* CursorAdapter;
399 const session = yield* adapter.startSession({
400 provider: "cursor",
401 threadId: THREAD_ID,
402 cwd: "/tmp/project",
403 runtimeMode: "full-access",
404 });
405
406 assert.equal(nativeEvents.length > 0, true);
407 assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true);
408 assert.equal(nativeEvents.some((record) => record.event?.threadId === session.threadId), true);
409 assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true);
410 }).pipe(Effect.provide(layer));
411 });
412
413 it.effect("resumes ACP session using resumeCursor.acpSessionId", () => {
414 const fake = new FakeCursorAcpProcess();
415 const layer = makeCursorAdapterLive({
416 createProcess: () => fake as never,
417 });
418
419 return Effect.gen(function* () {
420 const adapter = yield* CursorAdapter;
421 const session = yield* adapter.startSession({
422 provider: "cursor",
423 threadId: RESUME_THREAD_ID,
424 cwd: "/tmp/project",
425 resumeCursor: {
426 acpSessionId: "acp-session-resume",
427 },
428 runtimeMode: "full-access",
429 });
430
431 const methods = new Set(fake.requests.map((request) => request.method));
432 assert.equal(methods.has("session/load"), true);
433 assert.equal(methods.has("session/new"), false);
434
435 const loadRequest = fake.requests.find((request) => request.method === "session/load");
436 assert.deepEqual(loadRequest?.params, {
437 sessionId: "acp-session-resume",
438 cwd: "/tmp/project",
439 mcpServers: [],
440 });
441 assert.equal(session.threadId, RESUME_THREAD_ID);
442 assert.deepEqual(session.resumeCursor, {
443 acpSessionId: "acp-session-resume",
444 });
445 }).pipe(Effect.provide(layer));
446 });
447
448 it.effect("accepts legacy resumeCursor.sessionId for ACP session resume", () => {
449 const fake = new FakeCursorAcpProcess();
450 const layer = makeCursorAdapterLive({
451 createProcess: () => fake as never,
452 });
453
454 return Effect.gen(function* () {
455 const adapter = yield* CursorAdapter;
456 const session = yield* adapter.startSession({
457 provider: "cursor",
458 threadId: LEGACY_RESUME_THREAD_ID,
459 cwd: "/tmp/project",
460 resumeCursor: {
461 sessionId: "acp-session-legacy",
462 },
463 runtimeMode: "full-access",
464 });
465
466 const loadRequest = fake.requests.find((request) => request.method === "session/load");
467 assert.deepEqual(loadRequest?.params, {
468 sessionId: "acp-session-legacy",
469 cwd: "/tmp/project",
470 mcpServers: [],
471 });
472 assert.equal(session.threadId, LEGACY_RESUME_THREAD_ID);
473 assert.deepEqual(session.resumeCursor, {
474 acpSessionId: "acp-session-legacy",
475 });
476 }).pipe(Effect.provide(layer));
477 });
478
479 it.effect("bridges permission requests to request.opened/request.resolved", () => {
480 const fake = new FakeCursorAcpProcess();
481 const layer = makeCursorAdapterLive({
482 createProcess: () => fake as never,
483 });
484
485 return Effect.gen(function* () {
486 const adapter = yield* CursorAdapter;
487
488 const session = yield* adapter.startSession({
489 provider: "cursor",
490 threadId: THREAD_ID,
491 runtimeMode: "approval-required",
492 });
493
494 // consume startup events
495 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
496
497 fake.emitPermissionRequest();
498
499 const opened = yield* Stream.runHead(adapter.streamEvents);
500 assert.equal(opened._tag, "Some");
501 if (opened._tag !== "Some") {
502 return;
503 }
504 assert.equal(opened.value.type, "request.opened");
505 if (opened.value.type !== "request.opened") {
506 return;
507 }
508 const runtimeRequestId = opened.value.requestId;
509 assert.equal(typeof runtimeRequestId, "string");
510 if (runtimeRequestId === undefined) {
511 return;
512 }
513
514 yield* adapter.respondToRequest(
515 session.threadId,
516 ApprovalRequestId.makeUnsafe(runtimeRequestId),
517 "acceptForSession",
518 );
519
520 const resolved = yield* Stream.runHead(adapter.streamEvents);
521 assert.equal(resolved._tag, "Some");
522 if (resolved._tag !== "Some") {
523 return;
524 }
525 assert.equal(resolved.value.type, "request.resolved");
526 if (resolved.value.type !== "request.resolved") {
527 return;
528 }
529 assert.equal(resolved.value.payload.decision, "acceptForSession");
530 assert.equal(fake.lastPermissionSelection, "allow-always");
531 }).pipe(Effect.provide(layer));
532 });
533
534 it.effect("auto-approves cursor permission requests when approval policy is never", () => {
535 const fake = new FakeCursorAcpProcess();
536 const layer = makeCursorAdapterLive({
537 createProcess: () => fake as never,
538 });
539
540 return Effect.gen(function* () {
541 const adapter = yield* CursorAdapter;
542
543 yield* adapter.startSession({
544 provider: "cursor",
545 threadId: THREAD_ID,
546 runtimeMode: "full-access",
547 });
548
549 // consume startup events
550 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
551
552 fake.emitPermissionRequest();
553
554 const resolved = yield* Stream.runHead(adapter.streamEvents);
555 assert.equal(resolved._tag, "Some");
556 if (resolved._tag !== "Some") {
557 return;
558 }
559
560 assert.equal(resolved.value.type, "request.resolved");
561 if (resolved.value.type !== "request.resolved") {
562 return;
563 }
564
565 assert.equal(resolved.value.payload.decision, "acceptForSession");
566 assert.equal(fake.lastPermissionSelection, "allow-always");
567 }).pipe(Effect.provide(layer));
568 });
569
570 it.effect("rejects empty prompt input before starting a turn", () => {
571 const fake = new FakeCursorAcpProcess();
572 const layer = makeCursorAdapterLive({
573 createProcess: () => fake as never,
574 });
575
576 return Effect.gen(function* () {
577 const adapter = yield* CursorAdapter;
578 const session = yield* adapter.startSession({
579 provider: "cursor",
580 threadId: THREAD_ID,
581 runtimeMode: "full-access",
582 });
583
584 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
585
586 const result = yield* adapter
587 .sendTurn({
588 threadId: session.threadId,
589 input: " ",
590 attachments: [],
591 })
592 .pipe(Effect.result);
593
594 assert.equal(result._tag, "Failure");
595 if (result._tag !== "Failure") {
596 return;
597 }
598 assert.deepEqual(
599 result.failure,
600 new ProviderAdapterValidationError({
601 provider: "cursor",
602 operation: "sendTurn",
603 issue: "Turn input must be non-empty.",
604 }),
605 );
606
607 assert.equal(fake.requests.some((request) => request.method === "session/prompt"), false);
608 }).pipe(Effect.provide(layer));
609 });
610
611 it.effect("keeps tool_call item types consistent through tool_call_update", () => {
612 const fake = new FakeCursorAcpProcess();
613 const layer = makeCursorAdapterLive({
614 createProcess: () => fake as never,
615 });
616
617 return Effect.gen(function* () {
618 const adapter = yield* CursorAdapter;
619
620 const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe(
621 Stream.runCollect,
622 Effect.forkChild,
623 );
624
625 const session = yield* adapter.startSession({
626 provider: "cursor",
627 threadId: THREAD_ID,
628 runtimeMode: "full-access",
629 });
630
631 yield* adapter.sendTurn({
632 threadId: session.threadId,
633 input: "hello",
634 attachments: [],
635 });
636
637 const events = Array.from(yield* Fiber.join(eventsFiber));
638 const started = events.find(
639 (event) => event.type === "item.started" && String(event.itemId) === "tool-1",
640 );
641 const completed = events.find(
642 (event) => event.type === "item.completed" && String(event.itemId) === "tool-1",
643 );
644
645 assert.equal(started?.type, "item.started");
646 assert.equal(completed?.type, "item.completed");
647 if (started?.type !== "item.started" || completed?.type !== "item.completed") {
648 return;
649 }
650
651 assert.equal(started.payload.itemType, "command_execution");
652 assert.equal(completed.payload.itemType, "command_execution");
653 }).pipe(Effect.provide(layer));
654 });
655});
apps/server/src/provider/Layers/CursorAdapter.ts deleted-1566
...@@ -1,1566 +0,0 @@
1/**
2 * CursorAdapterLive - Scoped live implementation for the Cursor ACP provider adapter.
3 *
4 * Spawns `agent acp` over stdio, manages JSON-RPC session lifecycle, and maps
5 * ACP notifications/requests into canonical provider runtime events.
6 *
7 * @module CursorAdapterLive
8 */
9import { randomUUID } from "node:crypto";
10import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
11import readline from "node:readline";
12
13import {
14 ApprovalRequestId,
15 EventId,
16 type CanonicalItemType,
17 type CanonicalRequestType,
18 ProviderItemId,
19 type ProviderRuntimeEvent,
20 type ProviderSession,
21 type ProviderTurnStartResult,
22 type RuntimeMode,
23 RuntimeItemId,
24 RuntimeRequestId,
25 ThreadId,
26 TurnId,
27} from "@t3tools/contracts";
28import { DateTime, Effect, Layer, Queue, Random, Schema, Stream } from "effect";
29
30import {
31 ProviderAdapterProcessError,
32 ProviderAdapterRequestError,
33 ProviderAdapterSessionClosedError,
34 ProviderAdapterSessionNotFoundError,
35 ProviderAdapterValidationError,
36 type ProviderAdapterError,
37} from "../Errors.ts";
38import {
39 CursorAdapter,
40 type CursorAdapterShape,
41 CursorAcpInitializeResult,
42 CursorAcpPermissionRequest,
43 CursorAcpSessionNewResult,
44 CursorAcpSessionPromptResult,
45 CursorAcpSessionUpdateNotification,
46} from "../Services/CursorAdapter.ts";
47import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
48
49const PROVIDER = "cursor" as const;
50const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
51const CURSOR_ACP_PROTOCOL_VERSION = 1;
52
53interface CursorResumeState {
54 readonly acpSessionId?: string;
55}
56
57interface PendingRequest {
58 readonly method: string;
59 readonly timeout: ReturnType<typeof setTimeout>;
60 readonly resolve: (value: unknown) => void;
61 readonly reject: (error: Error) => void;
62}
63
64interface PendingPermission {
65 readonly jsonRpcId: string | number;
66 readonly requestType: CanonicalRequestType;
67 readonly options: ReadonlyArray<{ optionId: string }>;
68}
69
70interface CursorTurnState {
71 readonly turnId: TurnId;
72 readonly assistantItemId: ReturnType<typeof ProviderItemId.makeUnsafe>;
73 readonly startedToolCalls: Set<string>;
74 readonly toolCalls: Map<string, { itemType: CanonicalItemType; title: string }>;
75 readonly items: Array<unknown>;
76}
77
78interface CursorSessionContext {
79 session: ProviderSession;
80 runtimeMode: RuntimeMode;
81 readonly child: ChildProcessWithoutNullStreams;
82 readonly output: readline.Interface;
83 readonly pending: Map<string, PendingRequest>;
84 readonly pendingPermissions: Map<ApprovalRequestId, PendingPermission>;
85 readonly turns: Array<{
86 id: TurnId;
87 items: Array<unknown>;
88 }>;
89 turnState: CursorTurnState | undefined;
90 acpSessionId: string;
91 nextRpcId: number;
92 stopping: boolean;
93}
94
95export interface CursorAdapterLiveOptions {
96 readonly createProcess?: (input: {
97 readonly binaryPath: string;
98 readonly cwd: string;
99 readonly env: NodeJS.ProcessEnv;
100 readonly model?: string;
101 }) => ChildProcessWithoutNullStreams;
102 readonly nativeEventLogPath?: string;
103 readonly nativeEventLogger?: EventNdjsonLogger;
104}
105
106function toMessage(cause: unknown, fallback: string): string {
107 if (cause instanceof Error && cause.message.length > 0) {
108 return cause.message;
109 }
110 return fallback;
111}
112
113function asRuntimeItemId(value: string): RuntimeItemId {
114 return RuntimeItemId.makeUnsafe(value);
115}
116
117function asProviderItemId(value: string): ProviderItemId {
118 return ProviderItemId.makeUnsafe(value);
119}
120
121function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId {
122 return RuntimeRequestId.makeUnsafe(value);
123}
124
125function toSessionError(
126 threadId: ThreadId,
127 cause: unknown,
128): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
129 const normalized = toMessage(cause, "").toLowerCase();
130 if (normalized.includes("unknown session") || normalized.includes("not found")) {
131 return new ProviderAdapterSessionNotFoundError({
132 provider: PROVIDER,
133 threadId,
134 cause,
135 });
136 }
137 if (normalized.includes("closed")) {
138 return new ProviderAdapterSessionClosedError({
139 provider: PROVIDER,
140 threadId,
141 cause,
142 });
143 }
144 return undefined;
145}
146
147function toRequestError(
148 threadId: ThreadId,
149 method: string,
150 cause: unknown,
151): ProviderAdapterError {
152 const sessionError = toSessionError(threadId, cause);
153 if (sessionError) {
154 return sessionError;
155 }
156 return new ProviderAdapterRequestError({
157 provider: PROVIDER,
158 method,
159 detail: toMessage(cause, `${method} failed`),
160 cause,
161 });
162}
163
164function asObject(value: unknown): Record<string, unknown> | undefined {
165 if (!value || typeof value !== "object") {
166 return undefined;
167 }
168 return value as Record<string, unknown>;
169}
170
171function asString(value: unknown): string | undefined {
172 return typeof value === "string" ? value : undefined;
173}
174
175function normalizeToolItemType(kind: unknown, title: unknown): CanonicalItemType {
176 const normalizedKind = asString(kind)?.toLowerCase();
177 const normalizedTitle = asString(title)?.toLowerCase();
178
179 if (normalizedKind === "execute") {
180 return "command_execution";
181 }
182 if (normalizedKind === "edit" || normalizedKind === "write") {
183 return "file_change";
184 }
185 if (normalizedKind === "mcp") {
186 return "mcp_tool_call";
187 }
188 if (normalizedTitle?.includes("terminal")) {
189 return "command_execution";
190 }
191 return "dynamic_tool_call";
192}
193
194function normalizeRequestType(toolCall: unknown): CanonicalRequestType {
195 const record = asObject(toolCall);
196 const kind = asString(record?.kind)?.toLowerCase();
197 if (kind === "execute") {
198 return "command_execution_approval";
199 }
200 if (kind === "edit" || kind === "write") {
201 return "file_change_approval";
202 }
203 return "unknown";
204}
205
206function selectCursorPermissionOption(
207 options: ReadonlyArray<{ optionId: string }>,
208 decision: "acceptForSession" | "accept" | "decline" | "cancel",
209): string | undefined {
210 const allowAlways = options.find((option) => option.optionId === "allow-always");
211 const allowOnce = options.find((option) => option.optionId === "allow-once");
212 const rejectOnce = options.find((option) => option.optionId === "reject-once");
213
214 if (decision === "acceptForSession") {
215 return allowAlways?.optionId ?? allowOnce?.optionId;
216 }
217 if (decision === "accept") {
218 return allowOnce?.optionId ?? allowAlways?.optionId;
219 }
220 return rejectOnce?.optionId ?? options[0]?.optionId;
221}
222
223function selectCursorAutoApprovalOption(
224 options: ReadonlyArray<{ optionId: string }>,
225): { optionId: string; decision: "acceptForSession" | "accept" } | undefined {
226 const allowAlways = options.find((option) => option.optionId === "allow-always");
227 if (allowAlways) {
228 return {
229 optionId: allowAlways.optionId,
230 decision: "acceptForSession",
231 };
232 }
233 const allowOnce = options.find((option) => option.optionId === "allow-once");
234 if (allowOnce) {
235 return {
236 optionId: allowOnce.optionId,
237 decision: "accept",
238 };
239 }
240 return undefined;
241}
242
243function titleForItemType(itemType: CanonicalItemType): string {
244 switch (itemType) {
245 case "command_execution":
246 return "Command run";
247 case "file_change":
248 return "File change";
249 case "mcp_tool_call":
250 return "MCP tool call";
251 case "dynamic_tool_call":
252 return "Tool call";
253 default:
254 return "Item";
255 }
256}
257
258function summarizeToolOutput(rawOutput: unknown): string | undefined {
259 const output = asObject(rawOutput);
260 if (!output) return undefined;
261
262 const stdout = asString(output.stdout);
263 if (stdout && stdout.trim().length > 0) {
264 return stdout.trim().slice(0, 400);
265 }
266
267 const summary = JSON.stringify(output);
268 return summary.length > 400 ? `${summary.slice(0, 397)}...` : summary;
269}
270
271function mapStopReasonToTurnState(
272 stopReason: string | undefined,
273): "completed" | "failed" | "interrupted" | "cancelled" {
274 if (stopReason === "cancelled") return "cancelled";
275 if (stopReason === "interrupted") return "interrupted";
276 return "completed";
277}
278
279function readCursorResumeState(resumeCursor: unknown): CursorResumeState | undefined {
280 if (!resumeCursor || typeof resumeCursor !== "object") {
281 return undefined;
282 }
283
284 const cursor = resumeCursor as {
285 acpSessionId?: unknown;
286 sessionId?: unknown;
287 };
288
289 const acpSessionId =
290 typeof cursor.acpSessionId === "string"
291 ? cursor.acpSessionId
292 : typeof cursor.sessionId === "string"
293 ? cursor.sessionId
294 : undefined;
295
296 if (!acpSessionId) {
297 return {};
298 }
299 return { acpSessionId };
300}
301
302function writeCursorMessage(context: CursorSessionContext, message: unknown): void {
303 if (!context.child.stdin.writable) {
304 throw new Error("Cannot write to Cursor ACP stdin.");
305 }
306 context.child.stdin.write(`${JSON.stringify(message)}\n`);
307}
308
309function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
310 return Effect.gen(function* () {
311 const nativeEventLogger =
312 options?.nativeEventLogger ??
313 (options?.nativeEventLogPath !== undefined
314 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
315 stream: "native",
316 })
317 : undefined);
318
319 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
320 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id));
321 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
322
323 const sessions = new Map<ThreadId, CursorSessionContext>();
324 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
325
326 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
327 Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid);
328
329 const spawnCursorAcp = (input: {
330 readonly binaryPath: string;
331 readonly cwd: string;
332 readonly env: NodeJS.ProcessEnv;
333 readonly model?: string;
334 }): ChildProcessWithoutNullStreams => {
335 if (options?.createProcess) {
336 return options.createProcess(input);
337 }
338 const args = input.model ? ["--model", input.model, "acp"] : ["acp"];
339 return spawn(input.binaryPath, args, {
340 cwd: input.cwd,
341 env: input.env,
342 stdio: ["pipe", "pipe", "pipe"],
343 });
344 };
345
346 const sendRequest = (
347 context: CursorSessionContext,
348 method: string,
349 params: unknown,
350 timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
351 ): Promise<unknown> => {
352 const id = context.nextRpcId;
353 context.nextRpcId += 1;
354
355 return new Promise((resolve, reject) => {
356 const timeout = setTimeout(() => {
357 context.pending.delete(String(id));
358 reject(new Error(`Timed out waiting for ${method}.`));
359 }, timeoutMs);
360
361 context.pending.set(String(id), {
362 method,
363 timeout,
364 resolve,
365 reject,
366 });
367
368 writeCursorMessage(context, {
369 jsonrpc: "2.0",
370 id,
371 method,
372 params,
373 });
374 });
375 };
376
377 const resolvePendingRequest = (
378 context: CursorSessionContext,
379 message: Record<string, unknown>,
380 ) => {
381 const id = message.id;
382 if (typeof id !== "string" && typeof id !== "number") {
383 return;
384 }
385 const pending = context.pending.get(String(id));
386 if (!pending) {
387 return;
388 }
389
390 clearTimeout(pending.timeout);
391 context.pending.delete(String(id));
392
393 const error = asObject(message.error);
394 if (error) {
395 pending.reject(new Error(`${pending.method} failed: ${JSON.stringify(error)}`));
396 return;
397 }
398
399 pending.resolve(message.result);
400 };
401
402 const decodePermissionRequest = Schema.decodeUnknownSync(CursorAcpPermissionRequest);
403 const decodeSessionUpdateNotification = Schema.decodeUnknownSync(
404 CursorAcpSessionUpdateNotification,
405 );
406
407 const emitRuntimeWarning = (
408 context: CursorSessionContext,
409 message: string,
410 detail?: unknown,
411 ): Effect.Effect<void> =>
412 Effect.gen(function* () {
413 const stamp = yield* makeEventStamp();
414 yield* offerRuntimeEvent({
415 type: "runtime.warning",
416 eventId: stamp.eventId,
417 provider: PROVIDER,
418 createdAt: stamp.createdAt,
419 threadId: context.session.threadId,
420 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
421 payload: {
422 message,
423 ...(detail !== undefined ? { detail } : {}),
424 },
425 ...(context.turnState
426 ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } }
427 : {}),
428 });
429 });
430
431 const completeTurn = (
432 context: CursorSessionContext,
433 state: "completed" | "failed" | "interrupted" | "cancelled",
434 errorMessage?: string,
435 stopReason?: string,
436 ): Effect.Effect<void> =>
437 Effect.gen(function* () {
438 const turnState = context.turnState;
439 if (!turnState) {
440 return;
441 }
442
443 const itemStamp = yield* makeEventStamp();
444 yield* offerRuntimeEvent({
445 type: "item.completed",
446 eventId: itemStamp.eventId,
447 provider: PROVIDER,
448 createdAt: itemStamp.createdAt,
449 threadId: context.session.threadId,
450 turnId: turnState.turnId,
451 itemId: asRuntimeItemId(turnState.assistantItemId),
452 payload: {
453 itemType: "assistant_message",
454 status: "completed",
455 title: "Assistant message",
456 },
457 providerRefs: {
458 providerTurnId: String(turnState.turnId),
459 providerItemId: turnState.assistantItemId,
460 },
461 });
462
463 context.turns.push({
464 id: turnState.turnId,
465 items: [...turnState.items],
466 });
467
468 const stamp = yield* makeEventStamp();
469 yield* offerRuntimeEvent({
470 type: "turn.completed",
471 eventId: stamp.eventId,
472 provider: PROVIDER,
473 createdAt: stamp.createdAt,
474 threadId: context.session.threadId,
475 turnId: turnState.turnId,
476 payload: {
477 state,
478 ...(stopReason ? { stopReason } : {}),
479 ...(errorMessage ? { errorMessage } : {}),
480 },
481 providerRefs: {
482 providerTurnId: String(turnState.turnId),
483 },
484 });
485
486 context.turnState = undefined;
487 context.session = {
488 ...context.session,
489 status: state === "failed" ? "error" : "ready",
490 activeTurnId: undefined,
491 ...(errorMessage ? { lastError: errorMessage } : {}),
492 updatedAt: yield* nowIso,
493 };
494 });
495
496 const handlePermissionRequest = (
497 context: CursorSessionContext,
498 request: unknown,
499 ): Effect.Effect<void> => {
500 let decoded: ReturnType<typeof decodePermissionRequest>;
501 try {
502 decoded = decodePermissionRequest(request);
503 } catch (error) {
504 return emitRuntimeWarning(
505 context,
506 "Failed to decode Cursor ACP permission request.",
507 error,
508 );
509 }
510
511 return Effect.gen(function* () {
512 const requestId = ApprovalRequestId.makeUnsafe(randomUUID());
513 const requestType = normalizeRequestType(decoded.params.toolCall);
514 const options = decoded.params.options.map((entry) => ({ optionId: entry.optionId }));
515 const detail = asString(asObject(decoded.params.toolCall)?.title);
516
517 if (context.runtimeMode === "full-access") {
518 const selection =
519 selectCursorAutoApprovalOption(options) ??
520 (options[0]
521 ? {
522 optionId: options[0].optionId,
523 decision: "accept",
524 }
525 : undefined);
526 if (!selection) {
527 return yield* emitRuntimeWarning(
528 context,
529 "Cursor ACP permission request contained no selectable options.",
530 decoded.params,
531 );
532 }
533
534 writeCursorMessage(context, {
535 jsonrpc: "2.0",
536 id: decoded.id,
537 result: {
538 outcome: {
539 outcome: "selected",
540 optionId: selection.optionId,
541 },
542 },
543 });
544
545 const stamp = yield* makeEventStamp();
546 yield* offerRuntimeEvent({
547 type: "request.resolved",
548 eventId: stamp.eventId,
549 provider: PROVIDER,
550 createdAt: stamp.createdAt,
551 threadId: context.session.threadId,
552 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
553 requestId: asRuntimeRequestId(requestId),
554 payload: {
555 requestType,
556 decision: selection.decision,
557 resolution: {
558 optionId: selection.optionId,
559 autoApproved: true,
560 },
561 },
562 providerRefs: {
563 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
564 providerRequestId: String(decoded.id),
565 },
566 raw: {
567 source: "cursor.acp.response",
568 method: "session/request_permission",
569 payload: {
570 optionId: selection.optionId,
571 autoApproved: true,
572 },
573 },
574 });
575 return;
576 }
577
578 context.pendingPermissions.set(requestId, {
579 jsonRpcId: decoded.id,
580 requestType,
581 options,
582 });
583
584 const stamp = yield* makeEventStamp();
585 yield* offerRuntimeEvent({
586 type: "request.opened",
587 eventId: stamp.eventId,
588 provider: PROVIDER,
589 createdAt: stamp.createdAt,
590 threadId: context.session.threadId,
591 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
592 requestId: asRuntimeRequestId(requestId),
593 payload: {
594 requestType,
595 ...(detail ? { detail } : {}),
596 args: decoded.params,
597 },
598 providerRefs: {
599 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
600 providerRequestId: String(decoded.id),
601 },
602 raw: {
603 source: "cursor.acp.request",
604 method: decoded.method,
605 payload: decoded,
606 },
607 });
608 });
609 };
610
611 const handleSessionUpdateNotification = (
612 context: CursorSessionContext,
613 notification: unknown,
614 ): Effect.Effect<void> => {
615 let decoded: ReturnType<typeof decodeSessionUpdateNotification>;
616 try {
617 decoded = decodeSessionUpdateNotification(notification);
618 } catch (error) {
619 return emitRuntimeWarning(
620 context,
621 "Failed to decode Cursor ACP session/update notification.",
622 error,
623 );
624 }
625
626 return Effect.gen(function* () {
627 const update = decoded.params.update;
628
629 const base = {
630 provider: PROVIDER,
631 threadId: context.session.threadId,
632 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
633 ...(context.turnState
634 ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } }
635 : {}),
636 raw: {
637 source: "cursor.acp.notification" as const,
638 method: decoded.method,
639 messageType: update.sessionUpdate,
640 payload: decoded,
641 },
642 };
643
644 switch (update.sessionUpdate) {
645 case "available_commands_update": {
646 const stamp = yield* makeEventStamp();
647 yield* offerRuntimeEvent({
648 ...base,
649 type: "session.configured",
650 eventId: stamp.eventId,
651 createdAt: stamp.createdAt,
652 payload: {
653 config: {
654 availableCommands: update.availableCommands,
655 },
656 },
657 });
658 return;
659 }
660
661 case "agent_thought_chunk": {
662 if (!context.turnState) return;
663 if (update.content.text.length === 0) return;
664 const stamp = yield* makeEventStamp();
665 yield* offerRuntimeEvent({
666 ...base,
667 type: "content.delta",
668 eventId: stamp.eventId,
669 createdAt: stamp.createdAt,
670 turnId: context.turnState.turnId,
671 itemId: asRuntimeItemId(context.turnState.assistantItemId),
672 payload: {
673 streamKind: "reasoning_text",
674 delta: update.content.text,
675 },
676 });
677 return;
678 }
679
680 case "agent_message_chunk": {
681 if (!context.turnState) return;
682 if (update.content.text.length === 0) return;
683 const stamp = yield* makeEventStamp();
684 yield* offerRuntimeEvent({
685 ...base,
686 type: "content.delta",
687 eventId: stamp.eventId,
688 createdAt: stamp.createdAt,
689 turnId: context.turnState.turnId,
690 itemId: asRuntimeItemId(context.turnState.assistantItemId),
691 payload: {
692 streamKind: "assistant_text",
693 delta: update.content.text,
694 },
695 });
696 return;
697 }
698
699 case "tool_call": {
700 if (!context.turnState) return;
701 const seen = context.turnState.startedToolCalls.has(update.toolCallId);
702 const itemType = normalizeToolItemType(update.kind, update.title);
703 const title = update.title ?? titleForItemType(itemType);
704 context.turnState.toolCalls.set(update.toolCallId, { itemType, title });
705 const detail = asString(asObject(update.rawInput)?.command);
706
707 if (!seen) {
708 context.turnState.startedToolCalls.add(update.toolCallId);
709 const stamp = yield* makeEventStamp();
710 yield* offerRuntimeEvent({
711 ...base,
712 type: "item.started",
713 eventId: stamp.eventId,
714 createdAt: stamp.createdAt,
715 turnId: context.turnState.turnId,
716 itemId: asRuntimeItemId(update.toolCallId),
717 payload: {
718 itemType,
719 status: "inProgress",
720 title,
721 ...(detail ? { detail } : {}),
722 ...(update.rawInput !== undefined ? { data: update.rawInput } : {}),
723 },
724 });
725 return;
726 }
727
728 const stamp = yield* makeEventStamp();
729 yield* offerRuntimeEvent({
730 ...base,
731 type: "item.updated",
732 eventId: stamp.eventId,
733 createdAt: stamp.createdAt,
734 turnId: context.turnState.turnId,
735 itemId: asRuntimeItemId(update.toolCallId),
736 payload: {
737 itemType,
738 status: "inProgress",
739 title,
740 ...(detail ? { detail } : {}),
741 ...(update.rawInput !== undefined ? { data: update.rawInput } : {}),
742 },
743 });
744 return;
745 }
746
747 case "tool_call_update": {
748 if (!context.turnState) return;
749 const status = update.status === "completed" ? "completed" : "inProgress";
750 const trackedTool = context.turnState.toolCalls.get(update.toolCallId);
751 const itemType = trackedTool?.itemType ?? "dynamic_tool_call";
752 const title = trackedTool?.title ?? titleForItemType(itemType);
753 const stamp = yield* makeEventStamp();
754 const eventType = update.status === "completed" ? "item.completed" : "item.updated";
755 yield* offerRuntimeEvent({
756 ...base,
757 type: eventType,
758 eventId: stamp.eventId,
759 createdAt: stamp.createdAt,
760 turnId: context.turnState.turnId,
761 itemId: asRuntimeItemId(update.toolCallId),
762 payload: {
763 itemType,
764 status,
765 title,
766 ...(summarizeToolOutput(update.rawOutput)
767 ? { detail: summarizeToolOutput(update.rawOutput) }
768 : {}),
769 ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}),
770 },
771 });
772 if (update.status === "completed") {
773 context.turnState.toolCalls.delete(update.toolCallId);
774 }
775 return;
776 }
777 }
778 });
779 };
780
781 const handleStdoutLine = (context: CursorSessionContext, line: string): void => {
782 let parsed: unknown;
783 try {
784 parsed = JSON.parse(line);
785 } catch {
786 Effect.runFork(
787 emitRuntimeWarning(context, "Received invalid JSON from Cursor ACP.", {
788 line,
789 }),
790 );
791 return;
792 }
793
794 const message = asObject(parsed);
795 if (!message) {
796 Effect.runFork(
797 emitRuntimeWarning(context, "Received non-object protocol message from Cursor ACP."),
798 );
799 return;
800 }
801
802 if (nativeEventLogger) {
803 try {
804 const nativeMethod =
805 typeof message.method === "string"
806 ? message.method
807 : typeof message.id === "string" || typeof message.id === "number"
808 ? "cursor/acp/response"
809 : "cursor/acp/message";
810 const nativeKind =
811 typeof message.method === "string" &&
812 (typeof message.id === "string" || typeof message.id === "number")
813 ? "request"
814 : typeof message.method === "string"
815 ? "notification"
816 : "session";
817 Effect.runFork(
818 nativeEventLogger
819 .write(
820 {
821 observedAt: new Date().toISOString(),
822 event: {
823 id: EventId.makeUnsafe(randomUUID()),
824 kind: nativeKind,
825 provider: PROVIDER,
826 createdAt: new Date().toISOString(),
827 method: nativeMethod,
828 threadId: context.session.threadId,
829 ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}),
830 payload: message,
831 },
832 },
833 null,
834 ),
835 );
836 } catch {
837 // Native logging must never block or break protocol handling.
838 }
839 }
840
841 if (
842 (typeof message.id === "string" || typeof message.id === "number") &&
843 typeof message.method === "string"
844 ) {
845 if (message.method === "session/request_permission") {
846 Effect.runFork(handlePermissionRequest(context, message));
847 return;
848 }
849
850 writeCursorMessage(context, {
851 jsonrpc: "2.0",
852 id: message.id,
853 error: {
854 code: -32601,
855 message: `Unsupported server request: ${message.method}`,
856 },
857 });
858 return;
859 }
860
861 if (
862 (typeof message.id === "string" || typeof message.id === "number") &&
863 ("result" in message || "error" in message)
864 ) {
865 resolvePendingRequest(context, message);
866 return;
867 }
868
869 if (typeof message.method === "string") {
870 if (message.method === "session/update") {
871 Effect.runFork(handleSessionUpdateNotification(context, message));
872 return;
873 }
874
875 Effect.runFork(
876 emitRuntimeWarning(
877 context,
878 `Unhandled Cursor ACP notification '${message.method}'.`,
879 message,
880 ),
881 );
882 return;
883 }
884
885 Effect.runFork(
886 emitRuntimeWarning(context, "Received unrecognized protocol message from Cursor ACP.", {
887 message,
888 }),
889 );
890 };
891
892 const stopSessionInternal = (
893 context: CursorSessionContext,
894 options?: { readonly emitExitEvent?: boolean },
895 ): Effect.Effect<void> =>
896 Effect.gen(function* () {
897 if (context.stopping) return;
898 context.stopping = true;
899
900 for (const pending of context.pending.values()) {
901 clearTimeout(pending.timeout);
902 pending.reject(new Error("Cursor session stopped before request completion."));
903 }
904 context.pending.clear();
905
906 if (context.turnState) {
907 yield* completeTurn(context, "interrupted", "Session stopped.", "cancelled");
908 }
909
910 context.output.close();
911 if (!context.child.killed) {
912 context.child.kill();
913 }
914
915 context.session = {
916 ...context.session,
917 status: "closed",
918 activeTurnId: undefined,
919 updatedAt: yield* nowIso,
920 };
921
922 if (options?.emitExitEvent !== false) {
923 const stamp = yield* makeEventStamp();
924 yield* offerRuntimeEvent({
925 type: "session.exited",
926 eventId: stamp.eventId,
927 provider: PROVIDER,
928 createdAt: stamp.createdAt,
929 threadId: context.session.threadId,
930 payload: {
931 reason: "Session stopped",
932 exitKind: "graceful",
933 },
934 });
935 }
936
937 sessions.delete(context.session.threadId);
938 });
939
940 const requireSession = (
941 threadId: ThreadId,
942 ): Effect.Effect<CursorSessionContext, ProviderAdapterError> => {
943 const context = sessions.get(threadId);
944 if (!context) {
945 return Effect.fail(
946 new ProviderAdapterSessionNotFoundError({
947 provider: PROVIDER,
948 threadId,
949 }),
950 );
951 }
952 if (context.stopping || context.session.status === "closed") {
953 return Effect.fail(
954 new ProviderAdapterSessionClosedError({
955 provider: PROVIDER,
956 threadId,
957 }),
958 );
959 }
960 return Effect.succeed(context);
961 };
962
963 const startSession: CursorAdapterShape["startSession"] = (input) =>
964 Effect.gen(function* () {
965 if (input.provider !== undefined && input.provider !== PROVIDER) {
966 return yield* new ProviderAdapterValidationError({
967 provider: PROVIDER,
968 operation: "startSession",
969 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
970 });
971 }
972
973 const startedAt = yield* nowIso;
974 const cwd = input.cwd ?? process.cwd();
975 const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined;
976 const binaryPath = cursorOptions?.binaryPath ?? "agent";
977 const resumeState = readCursorResumeState(input.resumeCursor);
978
979 const child = yield* Effect.try({
980 try: () =>
981 spawnCursorAcp({
982 binaryPath,
983 cwd,
984 env: process.env,
985 ...(input.model ? { model: input.model } : {}),
986 }),
987 catch: (cause) =>
988 new ProviderAdapterProcessError({
989 provider: PROVIDER,
990 threadId: input.threadId,
991 detail: toMessage(cause, "Failed to spawn Cursor ACP process."),
992 cause,
993 }),
994 });
995
996 const output = readline.createInterface({ input: child.stdout });
997
998 const session: ProviderSession = {
999 threadId: input.threadId,
1000 provider: PROVIDER,
1001 status: "connecting",
1002 runtimeMode: input.runtimeMode,
1003 ...(input.cwd ? { cwd: input.cwd } : {}),
1004 ...(input.model ? { model: input.model } : {}),
1005 createdAt: startedAt,
1006 updatedAt: startedAt,
1007 };
1008
1009 const context: CursorSessionContext = {
1010 session,
1011 runtimeMode: input.runtimeMode,
1012 child,
1013 output,
1014 pending: new Map(),
1015 pendingPermissions: new Map(),
1016 turns: [],
1017 turnState: undefined,
1018 acpSessionId: resumeState?.acpSessionId ?? "",
1019 nextRpcId: 1,
1020 stopping: false,
1021 };
1022
1023 output.on("line", (line) => {
1024 handleStdoutLine(context, line);
1025 });
1026
1027 child.stderr.on("data", (chunk: Buffer) => {
1028 const message = chunk.toString().trim();
1029 if (message.length === 0) {
1030 return;
1031 }
1032 Effect.runFork(
1033 emitRuntimeWarning(context, "Cursor ACP stderr output", {
1034 message,
1035 }),
1036 );
1037 });
1038
1039 child.on("error", (error) => {
1040 Effect.runFork(
1041 Effect.gen(function* () {
1042 const stamp = yield* makeEventStamp();
1043 yield* offerRuntimeEvent({
1044 type: "runtime.error",
1045 eventId: stamp.eventId,
1046 provider: PROVIDER,
1047 createdAt: stamp.createdAt,
1048 threadId: context.session.threadId,
1049 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
1050 payload: {
1051 message: error.message || "Cursor ACP process error.",
1052 class: "transport_error",
1053 detail: error,
1054 },
1055 });
1056 }),
1057 );
1058 });
1059
1060 child.on("exit", (code, signal) => {
1061 if (context.stopping) {
1062 return;
1063 }
1064 Effect.runFork(
1065 Effect.gen(function* () {
1066 if (context.turnState) {
1067 yield* completeTurn(
1068 context,
1069 "failed",
1070 `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`,
1071 );
1072 }
1073
1074 const stamp = yield* makeEventStamp();
1075 yield* offerRuntimeEvent({
1076 type: "session.exited",
1077 eventId: stamp.eventId,
1078 provider: PROVIDER,
1079 createdAt: stamp.createdAt,
1080 threadId: context.session.threadId,
1081 payload: {
1082 reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`,
1083 exitKind: code === 0 ? "graceful" : "error",
1084 recoverable: code === 0,
1085 },
1086 });
1087
1088 sessions.delete(context.session.threadId);
1089 }),
1090 );
1091 });
1092
1093 sessions.set(input.threadId, context);
1094
1095 const initializeResult = yield* Effect.tryPromise({
1096 try: async () =>
1097 sendRequest(context, "initialize", {
1098 protocolVersion: CURSOR_ACP_PROTOCOL_VERSION,
1099 }),
1100 catch: (cause) => toRequestError(input.threadId, "initialize", cause),
1101 });
1102 const decodedInitialize = yield* Effect.try({
1103 try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult),
1104 catch: (cause) =>
1105 new ProviderAdapterValidationError({
1106 provider: PROVIDER,
1107 operation: "startSession",
1108 issue: "Cursor initialize response did not match expected schema.",
1109 cause,
1110 }),
1111 });
1112
1113 const initStamp = yield* makeEventStamp();
1114 yield* offerRuntimeEvent({
1115 type: "session.configured",
1116 eventId: initStamp.eventId,
1117 provider: PROVIDER,
1118 createdAt: initStamp.createdAt,
1119 threadId: input.threadId,
1120 payload: {
1121 config: decodedInitialize,
1122 },
1123 raw: {
1124 source: "cursor.acp.response",
1125 method: "initialize",
1126 payload: initializeResult,
1127 },
1128 });
1129
1130 const authenticateRequest = { methodId: "cursor_login" };
1131 const authStartStamp = yield* makeEventStamp();
1132 yield* offerRuntimeEvent({
1133 type: "auth.status",
1134 eventId: authStartStamp.eventId,
1135 provider: PROVIDER,
1136 createdAt: authStartStamp.createdAt,
1137 threadId: input.threadId,
1138 payload: {
1139 isAuthenticating: true,
1140 },
1141 raw: {
1142 source: "cursor.acp.request",
1143 method: "authenticate",
1144 payload: authenticateRequest,
1145 },
1146 });
1147
1148 const authenticateResult = yield* Effect.tryPromise({
1149 try: async () => sendRequest(context, "authenticate", authenticateRequest),
1150 catch: (cause) => toRequestError(input.threadId, "authenticate", cause),
1151 }).pipe(
1152 Effect.match({
1153 onFailure: (error) => ({ ok: false as const, error }),
1154 onSuccess: (value) => ({ ok: true as const, value }),
1155 }),
1156 );
1157 const authEndStamp = yield* makeEventStamp();
1158 if (!authenticateResult.ok) {
1159 yield* offerRuntimeEvent({
1160 type: "auth.status",
1161 eventId: authEndStamp.eventId,
1162 provider: PROVIDER,
1163 createdAt: authEndStamp.createdAt,
1164 threadId: input.threadId,
1165 payload: {
1166 isAuthenticating: false,
1167 error: toMessage(authenticateResult.error, "Cursor authentication failed."),
1168 },
1169 raw: {
1170 source: "cursor.acp.response",
1171 method: "authenticate",
1172 payload: {
1173 error: toMessage(authenticateResult.error, "Cursor authentication failed."),
1174 },
1175 },
1176 });
1177 } else {
1178 yield* offerRuntimeEvent({
1179 type: "auth.status",
1180 eventId: authEndStamp.eventId,
1181 provider: PROVIDER,
1182 createdAt: authEndStamp.createdAt,
1183 threadId: input.threadId,
1184 payload: {
1185 isAuthenticating: false,
1186 },
1187 raw: {
1188 source: "cursor.acp.response",
1189 method: "authenticate",
1190 payload: authenticateResult.value,
1191 },
1192 });
1193 }
1194
1195 const acpSessionId = yield* Effect.tryPromise({
1196 try: async () => {
1197 if (resumeState?.acpSessionId) {
1198 await sendRequest(context, "session/load", {
1199 sessionId: resumeState.acpSessionId,
1200 cwd,
1201 mcpServers: [],
1202 });
1203 return resumeState.acpSessionId;
1204 }
1205
1206 const sessionNewParams: {
1207 cwd: string;
1208 mcpServers: [];
1209 model?: string;
1210 } = {
1211 cwd,
1212 mcpServers: [],
1213 };
1214 if (input.model) {
1215 sessionNewParams.model = input.model;
1216 }
1217 const result = await sendRequest(context, "session/new", sessionNewParams);
1218 const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result);
1219 return decoded.sessionId;
1220 },
1221 catch: (cause) => toRequestError(input.threadId, "session/new|session/load", cause),
1222 });
1223
1224 context.acpSessionId = acpSessionId;
1225 context.session = {
1226 ...context.session,
1227 status: "ready",
1228 resumeCursor: {
1229 acpSessionId,
1230 },
1231 updatedAt: yield* nowIso,
1232 };
1233
1234 const sessionStartedStamp = yield* makeEventStamp();
1235 yield* offerRuntimeEvent({
1236 type: "session.started",
1237 eventId: sessionStartedStamp.eventId,
1238 provider: PROVIDER,
1239 createdAt: sessionStartedStamp.createdAt,
1240 threadId: input.threadId,
1241 payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {},
1242 });
1243
1244 const threadStartedStamp = yield* makeEventStamp();
1245 yield* offerRuntimeEvent({
1246 type: "thread.started",
1247 eventId: threadStartedStamp.eventId,
1248 provider: PROVIDER,
1249 createdAt: threadStartedStamp.createdAt,
1250 threadId: input.threadId,
1251 payload: {
1252 providerThreadId: acpSessionId,
1253 },
1254 });
1255
1256 const readyStamp = yield* makeEventStamp();
1257 yield* offerRuntimeEvent({
1258 type: "session.state.changed",
1259 eventId: readyStamp.eventId,
1260 provider: PROVIDER,
1261 createdAt: readyStamp.createdAt,
1262 threadId: input.threadId,
1263 payload: {
1264 state: "ready",
1265 },
1266 });
1267
1268 return {
1269 ...context.session,
1270 };
1271 });
1272
1273 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>
1274 Effect.gen(function* () {
1275 const context = yield* requireSession(input.threadId);
1276
1277 if (context.turnState) {
1278 return yield* new ProviderAdapterValidationError({
1279 provider: PROVIDER,
1280 operation: "sendTurn",
1281 issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`,
1282 });
1283 }
1284
1285 const promptText = input.input?.trim();
1286 if (!promptText || promptText.length === 0) {
1287 return yield* new ProviderAdapterValidationError({
1288 provider: PROVIDER,
1289 operation: "sendTurn",
1290 issue: "Turn input must be non-empty.",
1291 });
1292 }
1293
1294 const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);
1295 const turnState: CursorTurnState = {
1296 turnId,
1297 assistantItemId: asProviderItemId(yield* Random.nextUUIDv4),
1298 startedToolCalls: new Set(),
1299 toolCalls: new Map(),
1300 items: [],
1301 };
1302
1303 context.turnState = turnState;
1304 context.session = {
1305 ...context.session,
1306 status: "running",
1307 activeTurnId: turnId,
1308 updatedAt: yield* nowIso,
1309 };
1310
1311 const startedStamp = yield* makeEventStamp();
1312 yield* offerRuntimeEvent({
1313 type: "turn.started",
1314 eventId: startedStamp.eventId,
1315 provider: PROVIDER,
1316 createdAt: startedStamp.createdAt,
1317 threadId: context.session.threadId,
1318 turnId,
1319 payload: input.model ? { model: input.model } : {},
1320 providerRefs: {
1321 providerTurnId: String(turnId),
1322 },
1323 });
1324
1325 const promptResultRaw = yield* Effect.tryPromise({
1326 try: async () =>
1327 sendRequest(context, "session/prompt", {
1328 sessionId: context.acpSessionId,
1329 prompt: [{ type: "text", text: promptText }],
1330 }),
1331 catch: (cause) => toRequestError(input.threadId, "session/prompt", cause),
1332 });
1333
1334 const promptResult = yield* Effect.try({
1335 try: () => Schema.decodeUnknownSync(CursorAcpSessionPromptResult)(promptResultRaw),
1336 catch: (cause) =>
1337 new ProviderAdapterValidationError({
1338 provider: PROVIDER,
1339 operation: "sendTurn",
1340 issue: "Cursor session/prompt response did not match expected schema.",
1341 cause,
1342 }),
1343 });
1344 const turnStateValue = mapStopReasonToTurnState(promptResult.stopReason);
1345 yield* completeTurn(
1346 context,
1347 turnStateValue,
1348 turnStateValue === "failed" ? "Cursor prompt failed." : undefined,
1349 promptResult.stopReason,
1350 );
1351
1352 context.session = {
1353 ...context.session,
1354 status: "ready",
1355 activeTurnId: undefined,
1356 updatedAt: yield* nowIso,
1357 resumeCursor: {
1358 acpSessionId: context.acpSessionId,
1359 },
1360 };
1361
1362 return {
1363 threadId: context.session.threadId,
1364 turnId,
1365 ...(context.session.resumeCursor !== undefined
1366 ? { resumeCursor: context.session.resumeCursor }
1367 : {}),
1368 } satisfies ProviderTurnStartResult;
1369 });
1370
1371 const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, _turnId) =>
1372 Effect.gen(function* () {
1373 const context = yield* requireSession(threadId);
1374 if (!context.turnState) {
1375 return;
1376 }
1377
1378 const cancelResult = yield* Effect.tryPromise({
1379 try: async () =>
1380 sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000),
1381 catch: (cause) => toRequestError(threadId, "session/cancel", cause),
1382 }).pipe(
1383 Effect.match({
1384 onFailure: (error) => ({ ok: false as const, error }),
1385 onSuccess: () => ({ ok: true as const }),
1386 }),
1387 );
1388
1389 if (!cancelResult.ok) {
1390 yield* emitRuntimeWarning(
1391 context,
1392 "Cursor ACP session/cancel is unavailable; marking turn as interrupted.",
1393 cancelResult.error,
1394 );
1395 }
1396
1397 yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled");
1398 });
1399
1400 const readThread: CursorAdapterShape["readThread"] = (threadId) =>
1401 Effect.gen(function* () {
1402 const context = yield* requireSession(threadId);
1403 return {
1404 threadId: context.session.threadId,
1405 turns: context.turns.map((turn) => ({
1406 id: turn.id,
1407 items: [...turn.items],
1408 })),
1409 };
1410 });
1411
1412 const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, _numTurns) =>
1413 Effect.fail(
1414 new ProviderAdapterRequestError({
1415 provider: PROVIDER,
1416 method: "thread/rollback",
1417 detail: `Cursor ACP does not support thread rollback for thread '${threadId}'.`,
1418 }),
1419 );
1420
1421 const respondToRequest: CursorAdapterShape["respondToRequest"] = (
1422 threadId,
1423 requestId,
1424 decision,
1425 ) =>
1426 Effect.gen(function* () {
1427 const context = yield* requireSession(threadId);
1428 const pending = context.pendingPermissions.get(requestId);
1429 if (!pending) {
1430 return yield* new ProviderAdapterRequestError({
1431 provider: PROVIDER,
1432 method: "session/request_permission",
1433 detail: `Unknown pending permission request: ${requestId}`,
1434 });
1435 }
1436
1437 const optionId = selectCursorPermissionOption(pending.options, decision);
1438
1439 if (!optionId) {
1440 return yield* new ProviderAdapterRequestError({
1441 provider: PROVIDER,
1442 method: "session/request_permission",
1443 detail: `No selectable permission options for request: ${requestId}`,
1444 });
1445 }
1446
1447 writeCursorMessage(context, {
1448 jsonrpc: "2.0",
1449 id: pending.jsonRpcId,
1450 result: {
1451 outcome: {
1452 outcome: "selected",
1453 optionId,
1454 },
1455 },
1456 });
1457
1458 context.pendingPermissions.delete(requestId);
1459
1460 const stamp = yield* makeEventStamp();
1461 yield* offerRuntimeEvent({
1462 type: "request.resolved",
1463 eventId: stamp.eventId,
1464 provider: PROVIDER,
1465 createdAt: stamp.createdAt,
1466 threadId: context.session.threadId,
1467 ...((context.turnState ? { turnId: context.turnState.turnId } : {})),
1468 requestId: asRuntimeRequestId(requestId),
1469 payload: {
1470 requestType: pending.requestType,
1471 decision,
1472 resolution: {
1473 optionId,
1474 },
1475 },
1476 providerRefs: {
1477 ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}),
1478 providerRequestId: String(pending.jsonRpcId),
1479 },
1480 raw: {
1481 source: "cursor.acp.response",
1482 method: "session/request_permission",
1483 payload: {
1484 optionId,
1485 },
1486 },
1487 });
1488 });
1489
1490 const respondToUserInput: CursorAdapterShape["respondToUserInput"] = (
1491 threadId,
1492 requestId,
1493 _answers,
1494 ) =>
1495 Effect.fail(
1496 new ProviderAdapterRequestError({
1497 provider: PROVIDER,
1498 method: "cursor/ask_question",
1499 detail: `Cursor does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`,
1500 }),
1501 );
1502
1503 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
1504 Effect.gen(function* () {
1505 const context = yield* requireSession(threadId);
1506 yield* stopSessionInternal(context, {
1507 emitExitEvent: true,
1508 });
1509 });
1510
1511 const listSessions: CursorAdapterShape["listSessions"] = () =>
1512 Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session })));
1513
1514 const hasSession: CursorAdapterShape["hasSession"] = (threadId) =>
1515 Effect.sync(() => {
1516 const context = sessions.get(threadId);
1517 return context !== undefined && !context.stopping;
1518 });
1519
1520 const stopAll: CursorAdapterShape["stopAll"] = () =>
1521 Effect.forEach(
1522 sessions,
1523 ([, context]) =>
1524 stopSessionInternal(context, {
1525 emitExitEvent: true,
1526 }),
1527 { discard: true },
1528 );
1529
1530 yield* Effect.addFinalizer(() =>
1531 Effect.forEach(
1532 sessions,
1533 ([, context]) =>
1534 stopSessionInternal(context, {
1535 emitExitEvent: false,
1536 }),
1537 { discard: true },
1538 ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))),
1539 );
1540
1541 return {
1542 provider: PROVIDER,
1543 capabilities: {
1544 sessionModelSwitch: "unsupported",
1545 },
1546 startSession,
1547 sendTurn,
1548 interruptTurn,
1549 readThread,
1550 rollbackThread,
1551 respondToRequest,
1552 respondToUserInput,
1553 stopSession,
1554 listSessions,
1555 hasSession,
1556 stopAll,
1557 streamEvents: Stream.fromQueue(runtimeEventQueue),
1558 } satisfies CursorAdapterShape;
1559 });
1560}
1561
1562export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter());
1563
1564export function makeCursorAdapterLive(options?: CursorAdapterLiveOptions) {
1565 return Layer.effect(CursorAdapter, makeCursorAdapter(options));
1566}
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1-22
...@@ -6,7 +6,6 @@ import { Effect, Layer, Stream } from "effect";...@@ -6,7 +6,6 @@ import { Effect, Layer, Stream } from "effect";
66
7import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts";7import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts";
8import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts";8import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts";
9import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts";
10import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";9import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
11import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";10import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
12import { ProviderUnsupportedError } from "../Errors.ts";11import { ProviderUnsupportedError } from "../Errors.ts";
...@@ -46,23 +45,6 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = {...@@ -46,23 +45,6 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = {
46 streamEvents: Stream.empty,45 streamEvents: Stream.empty,
47};46};
4847
49const fakeCursorAdapter: CursorAdapterShape = {
50 provider: "cursor",
51 capabilities: { sessionModelSwitch: "unsupported" },
52 startSession: vi.fn(),
53 sendTurn: vi.fn(),
54 interruptTurn: vi.fn(),
55 respondToRequest: vi.fn(),
56 respondToUserInput: vi.fn(),
57 stopSession: vi.fn(),
58 listSessions: vi.fn(),
59 hasSession: vi.fn(),
60 readThread: vi.fn(),
61 rollbackThread: vi.fn(),
62 stopAll: vi.fn(),
63 streamEvents: Stream.empty,
64};
65
66const layer = it.layer(48const layer = it.layer(
67 Layer.mergeAll(49 Layer.mergeAll(
68 Layer.provide(50 Layer.provide(
...@@ -70,7 +52,6 @@ const layer = it.layer(...@@ -70,7 +52,6 @@ const layer = it.layer(
70 Layer.mergeAll(52 Layer.mergeAll(
71 Layer.succeed(CodexAdapter, fakeCodexAdapter),53 Layer.succeed(CodexAdapter, fakeCodexAdapter),
72 Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter),54 Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter),
73 Layer.succeed(CursorAdapter, fakeCursorAdapter),
74 ),55 ),
75 ),56 ),
76 NodeServices.layer,57 NodeServices.layer,
...@@ -83,13 +64,11 @@ layer("ProviderAdapterRegistryLive", (it) => {...@@ -83,13 +64,11 @@ layer("ProviderAdapterRegistryLive", (it) => {
83 const registry = yield* ProviderAdapterRegistry;64 const registry = yield* ProviderAdapterRegistry;
84 const codex = yield* registry.getByProvider("codex");65 const codex = yield* registry.getByProvider("codex");
85 const claude = yield* registry.getByProvider("claudeCode");66 const claude = yield* registry.getByProvider("claudeCode");
86 const cursor = yield* registry.getByProvider("cursor");
87 assert.equal(codex, fakeCodexAdapter);67 assert.equal(codex, fakeCodexAdapter);
88 assert.equal(claude, fakeClaudeAdapter);68 assert.equal(claude, fakeClaudeAdapter);
89 assert.equal(cursor, fakeCursorAdapter);
9069
91 const providers = yield* registry.listProviders();70 const providers = yield* registry.listProviders();
92 assert.deepEqual(providers, ["codex", "claudeCode", "cursor"]);71 assert.deepEqual(providers, ["codex", "claudeCode"]);
93 }),72 }),
94 );73 );
9574
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-2
...@@ -17,7 +17,6 @@ import {...@@ -17,7 +17,6 @@ import {
17} from "../Services/ProviderAdapterRegistry.ts";17} from "../Services/ProviderAdapterRegistry.ts";
18import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts";18import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts";
19import { CodexAdapter } from "../Services/CodexAdapter.ts";19import { CodexAdapter } from "../Services/CodexAdapter.ts";
20import { CursorAdapter } from "../Services/CursorAdapter.ts";
2120
22export interface ProviderAdapterRegistryLiveOptions {21export interface ProviderAdapterRegistryLiveOptions {
23 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;22 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;
...@@ -28,7 +27,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption...@@ -28,7 +27,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption
28 const adapters =27 const adapters =
29 options?.adapters !== undefined28 options?.adapters !== undefined
30 ? options.adapters29 ? options.adapters
31 : [yield* CodexAdapter, yield* CursorAdapter, yield* ClaudeCodeAdapter];30 : [yield* CodexAdapter, yield* ClaudeCodeAdapter];
32 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));31 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3332
34 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {33 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Services/CursorAdapter.test.ts deleted-131
...@@ -1,131 +0,0 @@
1import { Schema } from "effect";
2import { describe, expect, it } from "vitest";
3
4import {
5 CursorAcpPermissionRequest,
6 CursorAcpSessionPromptResult,
7 CursorAcpSessionUpdateNotification,
8} from "./CursorAdapter.ts";
9
10describe("Cursor ACP schemas", () => {
11 it("decodes session/update thought and message chunks", () => {
12 const thought = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({
13 jsonrpc: "2.0",
14 method: "session/update",
15 params: {
16 sessionId: "sess-1",
17 update: {
18 sessionUpdate: "agent_thought_chunk",
19 content: {
20 type: "text",
21 text: "thinking",
22 },
23 },
24 },
25 });
26
27 expect(thought.params.update.sessionUpdate).toBe("agent_thought_chunk");
28
29 const message = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({
30 jsonrpc: "2.0",
31 method: "session/update",
32 params: {
33 sessionId: "sess-1",
34 update: {
35 sessionUpdate: "agent_message_chunk",
36 content: {
37 type: "text",
38 text: "hello",
39 },
40 },
41 },
42 });
43
44 expect(message.params.update.sessionUpdate).toBe("agent_message_chunk");
45 });
46
47 it("decodes tool call lifecycle updates", () => {
48 const started = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({
49 jsonrpc: "2.0",
50 method: "session/update",
51 params: {
52 sessionId: "sess-1",
53 update: {
54 sessionUpdate: "tool_call",
55 toolCallId: "tool-1",
56 title: "Terminal",
57 kind: "execute",
58 status: "pending",
59 rawInput: { command: "pwd" },
60 },
61 },
62 });
63
64 expect(started.params.update.sessionUpdate).toBe("tool_call");
65
66 const completed = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({
67 jsonrpc: "2.0",
68 method: "session/update",
69 params: {
70 sessionId: "sess-1",
71 update: {
72 sessionUpdate: "tool_call_update",
73 toolCallId: "tool-1",
74 status: "completed",
75 rawOutput: {
76 exitCode: 0,
77 stdout: "ok",
78 stderr: "",
79 },
80 },
81 },
82 });
83
84 expect(completed.params.update.sessionUpdate).toBe("tool_call_update");
85 });
86
87 it("decodes permission requests", () => {
88 const decoded = Schema.decodeUnknownSync(CursorAcpPermissionRequest)({
89 jsonrpc: "2.0",
90 id: 9,
91 method: "session/request_permission",
92 params: {
93 sessionId: "sess-1",
94 toolCall: {
95 toolCallId: "tool-1",
96 kind: "execute",
97 },
98 options: [
99 { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
100 { optionId: "reject-once", name: "Reject", kind: "reject_once" },
101 ],
102 },
103 });
104
105 expect(decoded.method).toBe("session/request_permission");
106 expect(decoded.params.options).toHaveLength(2);
107 });
108
109 it("decodes prompt completion result payload", () => {
110 const decoded = Schema.decodeUnknownSync(CursorAcpSessionPromptResult)({
111 stopReason: "end_turn",
112 });
113
114 expect(decoded.stopReason).toBe("end_turn");
115 });
116
117 it("rejects unsupported update types", () => {
118 expect(() =>
119 Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({
120 jsonrpc: "2.0",
121 method: "session/update",
122 params: {
123 sessionId: "sess-1",
124 update: {
125 sessionUpdate: "unknown_update",
126 },
127 },
128 }),
129 ).toThrow();
130 });
131});
apps/server/src/provider/Services/CursorAdapter.ts deleted-110
...@@ -1,110 +0,0 @@
1/**
2 * CursorAdapter - Cursor ACP implementation of the generic provider adapter contract.
3 *
4 * Defines ACP JSON-RPC schemas used by the Cursor adapter layer.
5 *
6 * @module CursorAdapter
7 */
8import { Schema, ServiceMap } from "effect";
9
10import type { ProviderAdapterError } from "../Errors.ts";
11import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
12
13export const CursorAcpJsonRpcId = Schema.Union([Schema.String, Schema.Int]);
14export type CursorAcpJsonRpcId = typeof CursorAcpJsonRpcId.Type;
15
16export const CursorAcpTextContent = Schema.Struct({
17 type: Schema.Literal("text"),
18 text: Schema.String,
19});
20export type CursorAcpTextContent = typeof CursorAcpTextContent.Type;
21
22export const CursorAcpSessionUpdate = Schema.Union([
23 Schema.Struct({
24 sessionUpdate: Schema.Literal("available_commands_update"),
25 availableCommands: Schema.Array(
26 Schema.Struct({
27 name: Schema.String,
28 description: Schema.optional(Schema.String),
29 }),
30 ),
31 }),
32 Schema.Struct({
33 sessionUpdate: Schema.Literal("agent_thought_chunk"),
34 content: CursorAcpTextContent,
35 }),
36 Schema.Struct({
37 sessionUpdate: Schema.Literal("agent_message_chunk"),
38 content: CursorAcpTextContent,
39 }),
40 Schema.Struct({
41 sessionUpdate: Schema.Literal("tool_call"),
42 toolCallId: Schema.String,
43 title: Schema.optional(Schema.String),
44 kind: Schema.optional(Schema.String),
45 status: Schema.optional(Schema.String),
46 rawInput: Schema.optional(Schema.Unknown),
47 }),
48 Schema.Struct({
49 sessionUpdate: Schema.Literal("tool_call_update"),
50 toolCallId: Schema.String,
51 status: Schema.String,
52 rawOutput: Schema.optional(Schema.Unknown),
53 }),
54]);
55export type CursorAcpSessionUpdate = typeof CursorAcpSessionUpdate.Type;
56
57export const CursorAcpSessionUpdateNotification = Schema.Struct({
58 jsonrpc: Schema.optional(Schema.Literal("2.0")),
59 method: Schema.Literal("session/update"),
60 params: Schema.Struct({
61 sessionId: Schema.String,
62 update: CursorAcpSessionUpdate,
63 }),
64});
65export type CursorAcpSessionUpdateNotification = typeof CursorAcpSessionUpdateNotification.Type;
66
67export const CursorAcpPermissionOption = Schema.Struct({
68 optionId: Schema.String,
69 name: Schema.optional(Schema.String),
70 kind: Schema.optional(Schema.String),
71});
72export type CursorAcpPermissionOption = typeof CursorAcpPermissionOption.Type;
73
74export const CursorAcpPermissionRequest = Schema.Struct({
75 jsonrpc: Schema.optional(Schema.Literal("2.0")),
76 id: CursorAcpJsonRpcId,
77 method: Schema.Literal("session/request_permission"),
78 params: Schema.Struct({
79 sessionId: Schema.String,
80 toolCall: Schema.optional(Schema.Unknown),
81 options: Schema.Array(CursorAcpPermissionOption),
82 }),
83});
84export type CursorAcpPermissionRequest = typeof CursorAcpPermissionRequest.Type;
85
86export const CursorAcpInitializeResult = Schema.Struct({
87 protocolVersion: Schema.optional(Schema.Int),
88 agentCapabilities: Schema.optional(Schema.Unknown),
89 authMethods: Schema.optional(Schema.Array(Schema.Unknown)),
90});
91export type CursorAcpInitializeResult = typeof CursorAcpInitializeResult.Type;
92
93export const CursorAcpSessionNewResult = Schema.Struct({
94 sessionId: Schema.String,
95 modes: Schema.optional(Schema.Unknown),
96});
97export type CursorAcpSessionNewResult = typeof CursorAcpSessionNewResult.Type;
98
99export const CursorAcpSessionPromptResult = Schema.Struct({
100 stopReason: Schema.optional(Schema.String),
101});
102export type CursorAcpSessionPromptResult = typeof CursorAcpSessionPromptResult.Type;
103
104export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
105 readonly provider: "cursor";
106}
107
108export class CursorAdapter extends ServiceMap.Service<CursorAdapter, CursorAdapterShape>()(
109 "t3/provider/Services/CursorAdapter",
110) {}
apps/server/src/serverLayers.ts-5
...@@ -20,7 +20,6 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun...@@ -20,7 +20,6 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
20import { ProviderUnsupportedError } from "./provider/Errors";20import { ProviderUnsupportedError } from "./provider/Errors";
21import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter";21import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter";
22import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";22import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
23import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter";
24import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";23import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
25import { makeProviderServiceLive } from "./provider/Layers/ProviderService";24import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
26import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";25import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
...@@ -61,13 +60,9 @@ export function makeServerProviderLayer(): Layer.Layer<...@@ -61,13 +60,9 @@ export function makeServerProviderLayer(): Layer.Layer<
61 const claudeAdapterLayer = makeClaudeCodeAdapterLive(60 const claudeAdapterLayer = makeClaudeCodeAdapterLive(
62 nativeEventLogger ? { nativeEventLogger } : undefined,61 nativeEventLogger ? { nativeEventLogger } : undefined,
63 );62 );
64 const cursorAdapterLayer = makeCursorAdapterLive(
65 nativeEventLogger ? { nativeEventLogger } : undefined,
66 );
67 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(63 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
68 Layer.provide(codexAdapterLayer),64 Layer.provide(codexAdapterLayer),
69 Layer.provide(claudeAdapterLayer),65 Layer.provide(claudeAdapterLayer),
70 Layer.provide(cursorAdapterLayer),
71 Layer.provideMerge(providerSessionDirectoryLayer),66 Layer.provideMerge(providerSessionDirectoryLayer),
72 );67 );
73 return makeProviderServiceLive(68 return makeProviderServiceLive(
apps/web/src/routes/_chat.settings.tsx-7
...@@ -54,13 +54,6 @@ const MODEL_PROVIDER_SETTINGS: Array<{...@@ -54,13 +54,6 @@ const MODEL_PROVIDER_SETTINGS: Array<{
54 placeholder: "your-claude-model-slug",54 placeholder: "your-claude-model-slug",
55 example: "claude-sonnet-5-0",55 example: "claude-sonnet-5-0",
56 },56 },
57 {
58 provider: "cursor",
59 title: "Cursor",
60 description: "Save additional Cursor model slugs for the picker and `/model` command.",
61 placeholder: "your-cursor-model-slug",
62 example: "openai/gpt-oss-120b",
63 },
64] as const;57] as const;
6558
66function getCustomModelsForProvider(59function getCustomModelsForProvider(
apps/web/src/session-logic.test.ts+2-6
...@@ -523,12 +523,8 @@ describe("PROVIDER_OPTIONS", () => {...@@ -523,12 +523,8 @@ describe("PROVIDER_OPTIONS", () => {
523 });523 });
524 });524 });
525525
526 it("marks cursor as available", () => {526 it("does not advertise cursor as an available provider", () => {
527 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");527 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");
528 expect(cursor).toEqual({528 expect(cursor).toBeUndefined();
529 value: "cursor",
530 label: "Cursor Agent",
531 available: true,
532 });
533 });529 });
534});530});
apps/web/src/session-logic.ts-1
...@@ -17,7 +17,6 @@ export const PROVIDER_OPTIONS: Array<{...@@ -17,7 +17,6 @@ export const PROVIDER_OPTIONS: Array<{
17}> = [17}> = [
18 { value: "codex", label: "Codex", available: true },18 { value: "codex", label: "Codex", available: true },
19 { value: "claudeCode", label: "Claude Code", available: true },19 { value: "claudeCode", label: "Claude Code", available: true },
20 { value: "cursor", label: "Cursor Agent", available: true },
21];20];
2221
23export interface WorkLogEntry {22export interface WorkLogEntry {