diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts deleted file mode 100644 index 76a46d106e7beb77b82f37096d7d732447a28cfc..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ /dev/null @@ -1,655 +0,0 @@ -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import readline from "node:readline"; - -import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Fiber, Stream } from "effect"; - -import { ProviderAdapterValidationError } from "../Errors.ts"; -import { CursorAdapter } from "../Services/CursorAdapter.ts"; -import { makeCursorAdapterLive } from "./CursorAdapter.ts"; - -const THREAD_ID = ThreadId.makeUnsafe("thread-cursor-1"); -const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-resume"); -const LEGACY_RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-legacy"); - -class FakeCursorAcpProcess extends EventEmitter { - readonly stdin = new PassThrough(); - readonly stdout = new PassThrough(); - readonly stderr = new PassThrough(); - readonly requests: Array<{ method: string; params: unknown }> = []; - killed = false; - - private readonly input = readline.createInterface({ input: this.stdin }); - private permissionRequestId = 700; - lastPermissionSelection: string | undefined; - - constructor() { - super(); - this.input.on("line", (line) => { - const message = JSON.parse(line) as Record; - if (typeof message.method === "string") { - this.handleRequest(message); - return; - } - - if (message.id === this.permissionRequestId) { - const optionId = - (message.result as { outcome?: { optionId?: unknown } } | undefined)?.outcome?.optionId; - if (typeof optionId === "string") { - this.lastPermissionSelection = optionId; - } - } - }); - } - - kill(): boolean { - if (this.killed) { - return true; - } - this.killed = true; - this.emit("exit", 0, null); - return true; - } - - emitPermissionRequest(): void { - this.emitServerMessage({ - jsonrpc: "2.0", - id: this.permissionRequestId, - method: "session/request_permission", - params: { - sessionId: "acp-session-1", - toolCall: { - toolCallId: "tool-perm-1", - kind: "execute", - title: "`pwd`", - }, - options: [ - { optionId: "allow-once", kind: "allow_once" }, - { optionId: "allow-always", kind: "allow_always" }, - { optionId: "reject-once", kind: "reject_once" }, - ], - }, - }); - } - - private handleRequest(message: Record): void { - const method = message.method; - const id = message.id; - if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) { - return; - } - this.requests.push({ method, params: message.params }); - - switch (method) { - case "initialize": { - const protocolVersion = (message.params as { protocolVersion?: unknown } | undefined) - ?.protocolVersion; - if (typeof protocolVersion !== "number") { - this.emitServerMessage({ - jsonrpc: "2.0", - id, - error: { - code: -32602, - message: "Invalid params", - data: { - _errors: [], - protocolVersion: { - _errors: ["Invalid input: expected number, received undefined"], - }, - }, - }, - }); - return; - } - this.emitServerMessage({ - jsonrpc: "2.0", - id, - result: { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - }, - authMethods: [{ id: "cursor_login" }], - }, - }); - return; - } - case "authenticate": - this.emitServerMessage({ - jsonrpc: "2.0", - id, - result: {}, - }); - return; - case "session/new": - this.emitServerMessage({ - jsonrpc: "2.0", - id, - result: { - sessionId: "acp-session-1", - modes: { - currentModeId: "agent", - availableModes: [{ id: "agent" }], - }, - }, - }); - return; - case "session/load": - this.emitServerMessage({ - jsonrpc: "2.0", - id, - result: {}, - }); - return; - case "session/set_model": - this.emitServerMessage({ - jsonrpc: "2.0", - id, - result: {}, - }); - return; - case "session/prompt": { - this.emitServerMessage({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "acp-session-1", - update: { - sessionUpdate: "agent_thought_chunk", - content: { - type: "text", - text: "thinking", - }, - }, - }, - }); - - this.emitServerMessage({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "acp-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "hello", - }, - }, - }, - }); - - this.emitServerMessage({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "acp-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-1", - kind: "execute", - title: "`pwd`", - rawInput: { command: "pwd" }, - }, - }, - }); - - this.emitServerMessage({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "acp-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - status: "completed", - rawOutput: { - exitCode: 0, - stdout: "/tmp/project", - stderr: "", - }, - }, - }, - }); - - this.emitServerMessage({ - jsonrpc: "2.0", - id, - result: { - stopReason: "end_turn", - }, - }); - return; - } - case "session/cancel": - this.emitServerMessage({ - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: "Method not found", - }, - }); - return; - default: - this.emitServerMessage({ - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: `Unhandled method: ${method}`, - }, - }); - } - } - - private emitServerMessage(message: unknown): void { - this.stdout.write(`${JSON.stringify(message)}\n`); - } -} - -describe("CursorAdapterLive", () => { - it.effect("returns validation error for non-cursor provider on startSession", () => { - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const result = yield* adapter - .startSession({ - provider: "codex", - threadId: THREAD_ID, - runtimeMode: "full-access", - }) - .pipe(Effect.result); - - assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } - assert.deepEqual( - result.failure, - new ProviderAdapterValidationError({ - provider: "cursor", - operation: "startSession", - issue: "Expected provider 'cursor' but received 'codex'.", - }), - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("maps ACP prompt/update events into canonical runtime events", () => { - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - - const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - const session = yield* adapter.startSession({ - provider: "cursor", - threadId: THREAD_ID, - cwd: "/tmp/project", - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "hello", - attachments: [], - }); - - const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.deepEqual( - events.map((event) => event.type), - [ - "session.configured", - "auth.status", - "auth.status", - "session.started", - "thread.started", - "session.state.changed", - "turn.started", - "content.delta", - "content.delta", - "item.started", - "item.completed", - "item.completed", - "turn.completed", - ], - ); - - const turnStarted = events[6]; - assert.equal(turnStarted?.type, "turn.started"); - if (turnStarted?.type === "turn.started") { - assert.equal(String(turnStarted.turnId), String(turn.turnId)); - } - - const completion = events[12]; - assert.equal(completion?.type, "turn.completed"); - if (completion?.type === "turn.completed") { - assert.equal(completion.payload.state, "completed"); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("passes requested model to ACP process startup", () => { - const fake = new FakeCursorAcpProcess(); - let createProcessInput: - | { - readonly binaryPath: string; - readonly cwd: string; - readonly env: NodeJS.ProcessEnv; - readonly model?: string; - } - | undefined; - const layer = makeCursorAdapterLive({ - createProcess: (input) => { - createProcessInput = input; - return fake as never; - }, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - yield* adapter.startSession({ - provider: "cursor", - threadId: THREAD_ID, - model: "composer-1.5", - runtimeMode: "full-access", - }); - - assert.deepEqual(createProcessInput?.model, "composer-1.5"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("writes provider-native observability records when enabled", () => { - const nativeEvents: Array<{ - event?: { - provider?: string; - method?: string; - threadId?: string; - }; - }> = []; - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - nativeEventLogger: { - filePath: "memory://cursor-native-events", - write: (event) => { - nativeEvents.push(event as (typeof nativeEvents)[number]); - return Effect.void; - }, - close: () => Effect.void, - }, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const session = yield* adapter.startSession({ - provider: "cursor", - threadId: THREAD_ID, - cwd: "/tmp/project", - runtimeMode: "full-access", - }); - - assert.equal(nativeEvents.length > 0, true); - assert.equal(nativeEvents.some((record) => record.event?.provider === "cursor"), true); - assert.equal(nativeEvents.some((record) => record.event?.threadId === session.threadId), true); - assert.equal(nativeEvents.some((record) => record.event?.method === "cursor/acp/response"), true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("resumes ACP session using resumeCursor.acpSessionId", () => { - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const session = yield* adapter.startSession({ - provider: "cursor", - threadId: RESUME_THREAD_ID, - cwd: "/tmp/project", - resumeCursor: { - acpSessionId: "acp-session-resume", - }, - runtimeMode: "full-access", - }); - - const methods = new Set(fake.requests.map((request) => request.method)); - assert.equal(methods.has("session/load"), true); - assert.equal(methods.has("session/new"), false); - - const loadRequest = fake.requests.find((request) => request.method === "session/load"); - assert.deepEqual(loadRequest?.params, { - sessionId: "acp-session-resume", - cwd: "/tmp/project", - mcpServers: [], - }); - assert.equal(session.threadId, RESUME_THREAD_ID); - assert.deepEqual(session.resumeCursor, { - acpSessionId: "acp-session-resume", - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("accepts legacy resumeCursor.sessionId for ACP session resume", () => { - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const session = yield* adapter.startSession({ - provider: "cursor", - threadId: LEGACY_RESUME_THREAD_ID, - cwd: "/tmp/project", - resumeCursor: { - sessionId: "acp-session-legacy", - }, - runtimeMode: "full-access", - }); - - const loadRequest = fake.requests.find((request) => request.method === "session/load"); - assert.deepEqual(loadRequest?.params, { - sessionId: "acp-session-legacy", - cwd: "/tmp/project", - mcpServers: [], - }); - assert.equal(session.threadId, LEGACY_RESUME_THREAD_ID); - assert.deepEqual(session.resumeCursor, { - acpSessionId: "acp-session-legacy", - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("bridges permission requests to request.opened/request.resolved", () => { - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - - const session = yield* adapter.startSession({ - provider: "cursor", - threadId: THREAD_ID, - runtimeMode: "approval-required", - }); - - // consume startup events - yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); - - fake.emitPermissionRequest(); - - const opened = yield* Stream.runHead(adapter.streamEvents); - assert.equal(opened._tag, "Some"); - if (opened._tag !== "Some") { - return; - } - assert.equal(opened.value.type, "request.opened"); - if (opened.value.type !== "request.opened") { - return; - } - const runtimeRequestId = opened.value.requestId; - assert.equal(typeof runtimeRequestId, "string"); - if (runtimeRequestId === undefined) { - return; - } - - yield* adapter.respondToRequest( - session.threadId, - ApprovalRequestId.makeUnsafe(runtimeRequestId), - "acceptForSession", - ); - - const resolved = yield* Stream.runHead(adapter.streamEvents); - assert.equal(resolved._tag, "Some"); - if (resolved._tag !== "Some") { - return; - } - assert.equal(resolved.value.type, "request.resolved"); - if (resolved.value.type !== "request.resolved") { - return; - } - assert.equal(resolved.value.payload.decision, "acceptForSession"); - assert.equal(fake.lastPermissionSelection, "allow-always"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("auto-approves cursor permission requests when approval policy is never", () => { - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - - yield* adapter.startSession({ - provider: "cursor", - threadId: THREAD_ID, - runtimeMode: "full-access", - }); - - // consume startup events - yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); - - fake.emitPermissionRequest(); - - const resolved = yield* Stream.runHead(adapter.streamEvents); - assert.equal(resolved._tag, "Some"); - if (resolved._tag !== "Some") { - return; - } - - assert.equal(resolved.value.type, "request.resolved"); - if (resolved.value.type !== "request.resolved") { - return; - } - - assert.equal(resolved.value.payload.decision, "acceptForSession"); - assert.equal(fake.lastPermissionSelection, "allow-always"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects empty prompt input before starting a turn", () => { - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const session = yield* adapter.startSession({ - provider: "cursor", - threadId: THREAD_ID, - runtimeMode: "full-access", - }); - - yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); - - const result = yield* adapter - .sendTurn({ - threadId: session.threadId, - input: " ", - attachments: [], - }) - .pipe(Effect.result); - - assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") { - return; - } - assert.deepEqual( - result.failure, - new ProviderAdapterValidationError({ - provider: "cursor", - operation: "sendTurn", - issue: "Turn input must be non-empty.", - }), - ); - - assert.equal(fake.requests.some((request) => request.method === "session/prompt"), false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("keeps tool_call item types consistent through tool_call_update", () => { - const fake = new FakeCursorAcpProcess(); - const layer = makeCursorAdapterLive({ - createProcess: () => fake as never, - }); - - return Effect.gen(function* () { - const adapter = yield* CursorAdapter; - - const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( - Stream.runCollect, - Effect.forkChild, - ); - - const session = yield* adapter.startSession({ - provider: "cursor", - threadId: THREAD_ID, - runtimeMode: "full-access", - }); - - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "hello", - attachments: [], - }); - - const events = Array.from(yield* Fiber.join(eventsFiber)); - const started = events.find( - (event) => event.type === "item.started" && String(event.itemId) === "tool-1", - ); - const completed = events.find( - (event) => event.type === "item.completed" && String(event.itemId) === "tool-1", - ); - - assert.equal(started?.type, "item.started"); - assert.equal(completed?.type, "item.completed"); - if (started?.type !== "item.started" || completed?.type !== "item.completed") { - return; - } - - assert.equal(started.payload.itemType, "command_execution"); - assert.equal(completed.payload.itemType, "command_execution"); - }).pipe(Effect.provide(layer)); - }); -}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts deleted file mode 100644 index 15423b0c7ccc9d137aae355be8b86641765dc687..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ /dev/null @@ -1,1566 +0,0 @@ -/** - * CursorAdapterLive - Scoped live implementation for the Cursor ACP provider adapter. - * - * Spawns `agent acp` over stdio, manages JSON-RPC session lifecycle, and maps - * ACP notifications/requests into canonical provider runtime events. - * - * @module CursorAdapterLive - */ -import { randomUUID } from "node:crypto"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import readline from "node:readline"; - -import { - ApprovalRequestId, - EventId, - type CanonicalItemType, - type CanonicalRequestType, - ProviderItemId, - type ProviderRuntimeEvent, - type ProviderSession, - type ProviderTurnStartResult, - type RuntimeMode, - RuntimeItemId, - RuntimeRequestId, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import { DateTime, Effect, Layer, Queue, Random, Schema, Stream } from "effect"; - -import { - ProviderAdapterProcessError, - ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, - type ProviderAdapterError, -} from "../Errors.ts"; -import { - CursorAdapter, - type CursorAdapterShape, - CursorAcpInitializeResult, - CursorAcpPermissionRequest, - CursorAcpSessionNewResult, - CursorAcpSessionPromptResult, - CursorAcpSessionUpdateNotification, -} from "../Services/CursorAdapter.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; - -const PROVIDER = "cursor" as const; -const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; -const CURSOR_ACP_PROTOCOL_VERSION = 1; - -interface CursorResumeState { - readonly acpSessionId?: string; -} - -interface PendingRequest { - readonly method: string; - readonly timeout: ReturnType; - readonly resolve: (value: unknown) => void; - readonly reject: (error: Error) => void; -} - -interface PendingPermission { - readonly jsonRpcId: string | number; - readonly requestType: CanonicalRequestType; - readonly options: ReadonlyArray<{ optionId: string }>; -} - -interface CursorTurnState { - readonly turnId: TurnId; - readonly assistantItemId: ReturnType; - readonly startedToolCalls: Set; - readonly toolCalls: Map; - readonly items: Array; -} - -interface CursorSessionContext { - session: ProviderSession; - runtimeMode: RuntimeMode; - readonly child: ChildProcessWithoutNullStreams; - readonly output: readline.Interface; - readonly pending: Map; - readonly pendingPermissions: Map; - readonly turns: Array<{ - id: TurnId; - items: Array; - }>; - turnState: CursorTurnState | undefined; - acpSessionId: string; - nextRpcId: number; - stopping: boolean; -} - -export interface CursorAdapterLiveOptions { - readonly createProcess?: (input: { - readonly binaryPath: string; - readonly cwd: string; - readonly env: NodeJS.ProcessEnv; - readonly model?: string; - }) => ChildProcessWithoutNullStreams; - readonly nativeEventLogPath?: string; - readonly nativeEventLogger?: EventNdjsonLogger; -} - -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function asRuntimeItemId(value: string): RuntimeItemId { - return RuntimeItemId.makeUnsafe(value); -} - -function asProviderItemId(value: string): ProviderItemId { - return ProviderItemId.makeUnsafe(value); -} - -function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { - return RuntimeRequestId.makeUnsafe(value); -} - -function toSessionError( - threadId: ThreadId, - cause: unknown, -): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { - const normalized = toMessage(cause, "").toLowerCase(); - if (normalized.includes("unknown session") || normalized.includes("not found")) { - return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - cause, - }); - } - if (normalized.includes("closed")) { - return new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - cause, - }); - } - return undefined; -} - -function toRequestError( - threadId: ThreadId, - method: string, - cause: unknown, -): ProviderAdapterError { - const sessionError = toSessionError(threadId, cause); - if (sessionError) { - return sessionError; - } - return new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: toMessage(cause, `${method} failed`), - cause, - }); -} - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function normalizeToolItemType(kind: unknown, title: unknown): CanonicalItemType { - const normalizedKind = asString(kind)?.toLowerCase(); - const normalizedTitle = asString(title)?.toLowerCase(); - - if (normalizedKind === "execute") { - return "command_execution"; - } - if (normalizedKind === "edit" || normalizedKind === "write") { - return "file_change"; - } - if (normalizedKind === "mcp") { - return "mcp_tool_call"; - } - if (normalizedTitle?.includes("terminal")) { - return "command_execution"; - } - return "dynamic_tool_call"; -} - -function normalizeRequestType(toolCall: unknown): CanonicalRequestType { - const record = asObject(toolCall); - const kind = asString(record?.kind)?.toLowerCase(); - if (kind === "execute") { - return "command_execution_approval"; - } - if (kind === "edit" || kind === "write") { - return "file_change_approval"; - } - return "unknown"; -} - -function selectCursorPermissionOption( - options: ReadonlyArray<{ optionId: string }>, - decision: "acceptForSession" | "accept" | "decline" | "cancel", -): string | undefined { - const allowAlways = options.find((option) => option.optionId === "allow-always"); - const allowOnce = options.find((option) => option.optionId === "allow-once"); - const rejectOnce = options.find((option) => option.optionId === "reject-once"); - - if (decision === "acceptForSession") { - return allowAlways?.optionId ?? allowOnce?.optionId; - } - if (decision === "accept") { - return allowOnce?.optionId ?? allowAlways?.optionId; - } - return rejectOnce?.optionId ?? options[0]?.optionId; -} - -function selectCursorAutoApprovalOption( - options: ReadonlyArray<{ optionId: string }>, -): { optionId: string; decision: "acceptForSession" | "accept" } | undefined { - const allowAlways = options.find((option) => option.optionId === "allow-always"); - if (allowAlways) { - return { - optionId: allowAlways.optionId, - decision: "acceptForSession", - }; - } - const allowOnce = options.find((option) => option.optionId === "allow-once"); - if (allowOnce) { - return { - optionId: allowOnce.optionId, - decision: "accept", - }; - } - return undefined; -} - -function titleForItemType(itemType: CanonicalItemType): string { - switch (itemType) { - case "command_execution": - return "Command run"; - case "file_change": - return "File change"; - case "mcp_tool_call": - return "MCP tool call"; - case "dynamic_tool_call": - return "Tool call"; - default: - return "Item"; - } -} - -function summarizeToolOutput(rawOutput: unknown): string | undefined { - const output = asObject(rawOutput); - if (!output) return undefined; - - const stdout = asString(output.stdout); - if (stdout && stdout.trim().length > 0) { - return stdout.trim().slice(0, 400); - } - - const summary = JSON.stringify(output); - return summary.length > 400 ? `${summary.slice(0, 397)}...` : summary; -} - -function mapStopReasonToTurnState( - stopReason: string | undefined, -): "completed" | "failed" | "interrupted" | "cancelled" { - if (stopReason === "cancelled") return "cancelled"; - if (stopReason === "interrupted") return "interrupted"; - return "completed"; -} - -function readCursorResumeState(resumeCursor: unknown): CursorResumeState | undefined { - if (!resumeCursor || typeof resumeCursor !== "object") { - return undefined; - } - - const cursor = resumeCursor as { - acpSessionId?: unknown; - sessionId?: unknown; - }; - - const acpSessionId = - typeof cursor.acpSessionId === "string" - ? cursor.acpSessionId - : typeof cursor.sessionId === "string" - ? cursor.sessionId - : undefined; - - if (!acpSessionId) { - return {}; - } - return { acpSessionId }; -} - -function writeCursorMessage(context: CursorSessionContext, message: unknown): void { - if (!context.child.stdin.writable) { - throw new Error("Cannot write to Cursor ACP stdin."); - } - context.child.stdin.write(`${JSON.stringify(message)}\n`); -} - -function makeCursorAdapter(options?: CursorAdapterLiveOptions) { - return Effect.gen(function* () { - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); - - const sessions = new Map(); - const runtimeEventQueue = yield* Queue.unbounded(); - - const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); - - const spawnCursorAcp = (input: { - readonly binaryPath: string; - readonly cwd: string; - readonly env: NodeJS.ProcessEnv; - readonly model?: string; - }): ChildProcessWithoutNullStreams => { - if (options?.createProcess) { - return options.createProcess(input); - } - const args = input.model ? ["--model", input.model, "acp"] : ["acp"]; - return spawn(input.binaryPath, args, { - cwd: input.cwd, - env: input.env, - stdio: ["pipe", "pipe", "pipe"], - }); - }; - - const sendRequest = ( - context: CursorSessionContext, - method: string, - params: unknown, - timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - ): Promise => { - const id = context.nextRpcId; - context.nextRpcId += 1; - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - context.pending.delete(String(id)); - reject(new Error(`Timed out waiting for ${method}.`)); - }, timeoutMs); - - context.pending.set(String(id), { - method, - timeout, - resolve, - reject, - }); - - writeCursorMessage(context, { - jsonrpc: "2.0", - id, - method, - params, - }); - }); - }; - - const resolvePendingRequest = ( - context: CursorSessionContext, - message: Record, - ) => { - const id = message.id; - if (typeof id !== "string" && typeof id !== "number") { - return; - } - const pending = context.pending.get(String(id)); - if (!pending) { - return; - } - - clearTimeout(pending.timeout); - context.pending.delete(String(id)); - - const error = asObject(message.error); - if (error) { - pending.reject(new Error(`${pending.method} failed: ${JSON.stringify(error)}`)); - return; - } - - pending.resolve(message.result); - }; - - const decodePermissionRequest = Schema.decodeUnknownSync(CursorAcpPermissionRequest); - const decodeSessionUpdateNotification = Schema.decodeUnknownSync( - CursorAcpSessionUpdateNotification, - ); - - const emitRuntimeWarning = ( - context: CursorSessionContext, - message: string, - detail?: unknown, - ): Effect.Effect => - Effect.gen(function* () { - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "runtime.warning", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...((context.turnState ? { turnId: context.turnState.turnId } : {})), - payload: { - message, - ...(detail !== undefined ? { detail } : {}), - }, - ...(context.turnState - ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } } - : {}), - }); - }); - - const completeTurn = ( - context: CursorSessionContext, - state: "completed" | "failed" | "interrupted" | "cancelled", - errorMessage?: string, - stopReason?: string, - ): Effect.Effect => - Effect.gen(function* () { - const turnState = context.turnState; - if (!turnState) { - return; - } - - const itemStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.completed", - eventId: itemStamp.eventId, - provider: PROVIDER, - createdAt: itemStamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - itemId: asRuntimeItemId(turnState.assistantItemId), - payload: { - itemType: "assistant_message", - status: "completed", - title: "Assistant message", - }, - providerRefs: { - providerTurnId: String(turnState.turnId), - providerItemId: turnState.assistantItemId, - }, - }); - - context.turns.push({ - id: turnState.turnId, - items: [...turnState.items], - }); - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - turnId: turnState.turnId, - payload: { - state, - ...(stopReason ? { stopReason } : {}), - ...(errorMessage ? { errorMessage } : {}), - }, - providerRefs: { - providerTurnId: String(turnState.turnId), - }, - }); - - context.turnState = undefined; - context.session = { - ...context.session, - status: state === "failed" ? "error" : "ready", - activeTurnId: undefined, - ...(errorMessage ? { lastError: errorMessage } : {}), - updatedAt: yield* nowIso, - }; - }); - - const handlePermissionRequest = ( - context: CursorSessionContext, - request: unknown, - ): Effect.Effect => { - let decoded: ReturnType; - try { - decoded = decodePermissionRequest(request); - } catch (error) { - return emitRuntimeWarning( - context, - "Failed to decode Cursor ACP permission request.", - error, - ); - } - - return Effect.gen(function* () { - const requestId = ApprovalRequestId.makeUnsafe(randomUUID()); - const requestType = normalizeRequestType(decoded.params.toolCall); - const options = decoded.params.options.map((entry) => ({ optionId: entry.optionId })); - const detail = asString(asObject(decoded.params.toolCall)?.title); - - if (context.runtimeMode === "full-access") { - const selection = - selectCursorAutoApprovalOption(options) ?? - (options[0] - ? { - optionId: options[0].optionId, - decision: "accept", - } - : undefined); - if (!selection) { - return yield* emitRuntimeWarning( - context, - "Cursor ACP permission request contained no selectable options.", - decoded.params, - ); - } - - writeCursorMessage(context, { - jsonrpc: "2.0", - id: decoded.id, - result: { - outcome: { - outcome: "selected", - optionId: selection.optionId, - }, - }, - }); - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "request.resolved", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...((context.turnState ? { turnId: context.turnState.turnId } : {})), - requestId: asRuntimeRequestId(requestId), - payload: { - requestType, - decision: selection.decision, - resolution: { - optionId: selection.optionId, - autoApproved: true, - }, - }, - providerRefs: { - ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), - providerRequestId: String(decoded.id), - }, - raw: { - source: "cursor.acp.response", - method: "session/request_permission", - payload: { - optionId: selection.optionId, - autoApproved: true, - }, - }, - }); - return; - } - - context.pendingPermissions.set(requestId, { - jsonRpcId: decoded.id, - requestType, - options, - }); - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "request.opened", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...((context.turnState ? { turnId: context.turnState.turnId } : {})), - requestId: asRuntimeRequestId(requestId), - payload: { - requestType, - ...(detail ? { detail } : {}), - args: decoded.params, - }, - providerRefs: { - ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), - providerRequestId: String(decoded.id), - }, - raw: { - source: "cursor.acp.request", - method: decoded.method, - payload: decoded, - }, - }); - }); - }; - - const handleSessionUpdateNotification = ( - context: CursorSessionContext, - notification: unknown, - ): Effect.Effect => { - let decoded: ReturnType; - try { - decoded = decodeSessionUpdateNotification(notification); - } catch (error) { - return emitRuntimeWarning( - context, - "Failed to decode Cursor ACP session/update notification.", - error, - ); - } - - return Effect.gen(function* () { - const update = decoded.params.update; - - const base = { - provider: PROVIDER, - threadId: context.session.threadId, - ...((context.turnState ? { turnId: context.turnState.turnId } : {})), - ...(context.turnState - ? { providerRefs: { providerTurnId: String(context.turnState.turnId) } } - : {}), - raw: { - source: "cursor.acp.notification" as const, - method: decoded.method, - messageType: update.sessionUpdate, - payload: decoded, - }, - }; - - switch (update.sessionUpdate) { - case "available_commands_update": { - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - ...base, - type: "session.configured", - eventId: stamp.eventId, - createdAt: stamp.createdAt, - payload: { - config: { - availableCommands: update.availableCommands, - }, - }, - }); - return; - } - - case "agent_thought_chunk": { - if (!context.turnState) return; - if (update.content.text.length === 0) return; - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - ...base, - type: "content.delta", - eventId: stamp.eventId, - createdAt: stamp.createdAt, - turnId: context.turnState.turnId, - itemId: asRuntimeItemId(context.turnState.assistantItemId), - payload: { - streamKind: "reasoning_text", - delta: update.content.text, - }, - }); - return; - } - - case "agent_message_chunk": { - if (!context.turnState) return; - if (update.content.text.length === 0) return; - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - ...base, - type: "content.delta", - eventId: stamp.eventId, - createdAt: stamp.createdAt, - turnId: context.turnState.turnId, - itemId: asRuntimeItemId(context.turnState.assistantItemId), - payload: { - streamKind: "assistant_text", - delta: update.content.text, - }, - }); - return; - } - - case "tool_call": { - if (!context.turnState) return; - const seen = context.turnState.startedToolCalls.has(update.toolCallId); - const itemType = normalizeToolItemType(update.kind, update.title); - const title = update.title ?? titleForItemType(itemType); - context.turnState.toolCalls.set(update.toolCallId, { itemType, title }); - const detail = asString(asObject(update.rawInput)?.command); - - if (!seen) { - context.turnState.startedToolCalls.add(update.toolCallId); - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - ...base, - type: "item.started", - eventId: stamp.eventId, - createdAt: stamp.createdAt, - turnId: context.turnState.turnId, - itemId: asRuntimeItemId(update.toolCallId), - payload: { - itemType, - status: "inProgress", - title, - ...(detail ? { detail } : {}), - ...(update.rawInput !== undefined ? { data: update.rawInput } : {}), - }, - }); - return; - } - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - ...base, - type: "item.updated", - eventId: stamp.eventId, - createdAt: stamp.createdAt, - turnId: context.turnState.turnId, - itemId: asRuntimeItemId(update.toolCallId), - payload: { - itemType, - status: "inProgress", - title, - ...(detail ? { detail } : {}), - ...(update.rawInput !== undefined ? { data: update.rawInput } : {}), - }, - }); - return; - } - - case "tool_call_update": { - if (!context.turnState) return; - const status = update.status === "completed" ? "completed" : "inProgress"; - const trackedTool = context.turnState.toolCalls.get(update.toolCallId); - const itemType = trackedTool?.itemType ?? "dynamic_tool_call"; - const title = trackedTool?.title ?? titleForItemType(itemType); - const stamp = yield* makeEventStamp(); - const eventType = update.status === "completed" ? "item.completed" : "item.updated"; - yield* offerRuntimeEvent({ - ...base, - type: eventType, - eventId: stamp.eventId, - createdAt: stamp.createdAt, - turnId: context.turnState.turnId, - itemId: asRuntimeItemId(update.toolCallId), - payload: { - itemType, - status, - title, - ...(summarizeToolOutput(update.rawOutput) - ? { detail: summarizeToolOutput(update.rawOutput) } - : {}), - ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}), - }, - }); - if (update.status === "completed") { - context.turnState.toolCalls.delete(update.toolCallId); - } - return; - } - } - }); - }; - - const handleStdoutLine = (context: CursorSessionContext, line: string): void => { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - Effect.runFork( - emitRuntimeWarning(context, "Received invalid JSON from Cursor ACP.", { - line, - }), - ); - return; - } - - const message = asObject(parsed); - if (!message) { - Effect.runFork( - emitRuntimeWarning(context, "Received non-object protocol message from Cursor ACP."), - ); - return; - } - - if (nativeEventLogger) { - try { - const nativeMethod = - typeof message.method === "string" - ? message.method - : typeof message.id === "string" || typeof message.id === "number" - ? "cursor/acp/response" - : "cursor/acp/message"; - const nativeKind = - typeof message.method === "string" && - (typeof message.id === "string" || typeof message.id === "number") - ? "request" - : typeof message.method === "string" - ? "notification" - : "session"; - Effect.runFork( - nativeEventLogger - .write( - { - observedAt: new Date().toISOString(), - event: { - id: EventId.makeUnsafe(randomUUID()), - kind: nativeKind, - provider: PROVIDER, - createdAt: new Date().toISOString(), - method: nativeMethod, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: String(context.turnState.turnId) } : {}), - payload: message, - }, - }, - null, - ), - ); - } catch { - // Native logging must never block or break protocol handling. - } - } - - if ( - (typeof message.id === "string" || typeof message.id === "number") && - typeof message.method === "string" - ) { - if (message.method === "session/request_permission") { - Effect.runFork(handlePermissionRequest(context, message)); - return; - } - - writeCursorMessage(context, { - jsonrpc: "2.0", - id: message.id, - error: { - code: -32601, - message: `Unsupported server request: ${message.method}`, - }, - }); - return; - } - - if ( - (typeof message.id === "string" || typeof message.id === "number") && - ("result" in message || "error" in message) - ) { - resolvePendingRequest(context, message); - return; - } - - if (typeof message.method === "string") { - if (message.method === "session/update") { - Effect.runFork(handleSessionUpdateNotification(context, message)); - return; - } - - Effect.runFork( - emitRuntimeWarning( - context, - `Unhandled Cursor ACP notification '${message.method}'.`, - message, - ), - ); - return; - } - - Effect.runFork( - emitRuntimeWarning(context, "Received unrecognized protocol message from Cursor ACP.", { - message, - }), - ); - }; - - const stopSessionInternal = ( - context: CursorSessionContext, - options?: { readonly emitExitEvent?: boolean }, - ): Effect.Effect => - Effect.gen(function* () { - if (context.stopping) return; - context.stopping = true; - - for (const pending of context.pending.values()) { - clearTimeout(pending.timeout); - pending.reject(new Error("Cursor session stopped before request completion.")); - } - context.pending.clear(); - - if (context.turnState) { - yield* completeTurn(context, "interrupted", "Session stopped.", "cancelled"); - } - - context.output.close(); - if (!context.child.killed) { - context.child.kill(); - } - - context.session = { - ...context.session, - status: "closed", - activeTurnId: undefined, - updatedAt: yield* nowIso, - }; - - if (options?.emitExitEvent !== false) { - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.exited", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - payload: { - reason: "Session stopped", - exitKind: "graceful", - }, - }); - } - - sessions.delete(context.session.threadId); - }); - - const requireSession = ( - threadId: ThreadId, - ): Effect.Effect => { - const context = sessions.get(threadId); - if (!context) { - return Effect.fail( - new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }), - ); - } - if (context.stopping || context.session.status === "closed") { - return Effect.fail( - new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - }), - ); - } - return Effect.succeed(context); - }; - - const startSession: CursorAdapterShape["startSession"] = (input) => - Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } - - const startedAt = yield* nowIso; - const cwd = input.cwd ?? process.cwd(); - const cursorOptions = input.providerOptions?.cursor as { binaryPath?: string } | undefined; - const binaryPath = cursorOptions?.binaryPath ?? "agent"; - const resumeState = readCursorResumeState(input.resumeCursor); - - const child = yield* Effect.try({ - try: () => - spawnCursorAcp({ - binaryPath, - cwd, - env: process.env, - ...(input.model ? { model: input.model } : {}), - }), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: toMessage(cause, "Failed to spawn Cursor ACP process."), - cause, - }), - }); - - const output = readline.createInterface({ input: child.stdout }); - - const session: ProviderSession = { - threadId: input.threadId, - provider: PROVIDER, - status: "connecting", - runtimeMode: input.runtimeMode, - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(input.model ? { model: input.model } : {}), - createdAt: startedAt, - updatedAt: startedAt, - }; - - const context: CursorSessionContext = { - session, - runtimeMode: input.runtimeMode, - child, - output, - pending: new Map(), - pendingPermissions: new Map(), - turns: [], - turnState: undefined, - acpSessionId: resumeState?.acpSessionId ?? "", - nextRpcId: 1, - stopping: false, - }; - - output.on("line", (line) => { - handleStdoutLine(context, line); - }); - - child.stderr.on("data", (chunk: Buffer) => { - const message = chunk.toString().trim(); - if (message.length === 0) { - return; - } - Effect.runFork( - emitRuntimeWarning(context, "Cursor ACP stderr output", { - message, - }), - ); - }); - - child.on("error", (error) => { - Effect.runFork( - Effect.gen(function* () { - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "runtime.error", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...((context.turnState ? { turnId: context.turnState.turnId } : {})), - payload: { - message: error.message || "Cursor ACP process error.", - class: "transport_error", - detail: error, - }, - }); - }), - ); - }); - - child.on("exit", (code, signal) => { - if (context.stopping) { - return; - } - Effect.runFork( - Effect.gen(function* () { - if (context.turnState) { - yield* completeTurn( - context, - "failed", - `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, - ); - } - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.exited", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - payload: { - reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, - exitKind: code === 0 ? "graceful" : "error", - recoverable: code === 0, - }, - }); - - sessions.delete(context.session.threadId); - }), - ); - }); - - sessions.set(input.threadId, context); - - const initializeResult = yield* Effect.tryPromise({ - try: async () => - sendRequest(context, "initialize", { - protocolVersion: CURSOR_ACP_PROTOCOL_VERSION, - }), - catch: (cause) => toRequestError(input.threadId, "initialize", cause), - }); - const decodedInitialize = yield* Effect.try({ - try: () => Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult), - catch: (cause) => - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "Cursor initialize response did not match expected schema.", - cause, - }), - }); - - const initStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.configured", - eventId: initStamp.eventId, - provider: PROVIDER, - createdAt: initStamp.createdAt, - threadId: input.threadId, - payload: { - config: decodedInitialize, - }, - raw: { - source: "cursor.acp.response", - method: "initialize", - payload: initializeResult, - }, - }); - - const authenticateRequest = { methodId: "cursor_login" }; - const authStartStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "auth.status", - eventId: authStartStamp.eventId, - provider: PROVIDER, - createdAt: authStartStamp.createdAt, - threadId: input.threadId, - payload: { - isAuthenticating: true, - }, - raw: { - source: "cursor.acp.request", - method: "authenticate", - payload: authenticateRequest, - }, - }); - - const authenticateResult = yield* Effect.tryPromise({ - try: async () => sendRequest(context, "authenticate", authenticateRequest), - catch: (cause) => toRequestError(input.threadId, "authenticate", cause), - }).pipe( - Effect.match({ - onFailure: (error) => ({ ok: false as const, error }), - onSuccess: (value) => ({ ok: true as const, value }), - }), - ); - const authEndStamp = yield* makeEventStamp(); - if (!authenticateResult.ok) { - yield* offerRuntimeEvent({ - type: "auth.status", - eventId: authEndStamp.eventId, - provider: PROVIDER, - createdAt: authEndStamp.createdAt, - threadId: input.threadId, - payload: { - isAuthenticating: false, - error: toMessage(authenticateResult.error, "Cursor authentication failed."), - }, - raw: { - source: "cursor.acp.response", - method: "authenticate", - payload: { - error: toMessage(authenticateResult.error, "Cursor authentication failed."), - }, - }, - }); - } else { - yield* offerRuntimeEvent({ - type: "auth.status", - eventId: authEndStamp.eventId, - provider: PROVIDER, - createdAt: authEndStamp.createdAt, - threadId: input.threadId, - payload: { - isAuthenticating: false, - }, - raw: { - source: "cursor.acp.response", - method: "authenticate", - payload: authenticateResult.value, - }, - }); - } - - const acpSessionId = yield* Effect.tryPromise({ - try: async () => { - if (resumeState?.acpSessionId) { - await sendRequest(context, "session/load", { - sessionId: resumeState.acpSessionId, - cwd, - mcpServers: [], - }); - return resumeState.acpSessionId; - } - - const sessionNewParams: { - cwd: string; - mcpServers: []; - model?: string; - } = { - cwd, - mcpServers: [], - }; - if (input.model) { - sessionNewParams.model = input.model; - } - const result = await sendRequest(context, "session/new", sessionNewParams); - const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result); - return decoded.sessionId; - }, - catch: (cause) => toRequestError(input.threadId, "session/new|session/load", cause), - }); - - context.acpSessionId = acpSessionId; - context.session = { - ...context.session, - status: "ready", - resumeCursor: { - acpSessionId, - }, - updatedAt: yield* nowIso, - }; - - const sessionStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.started", - eventId: sessionStartedStamp.eventId, - provider: PROVIDER, - createdAt: sessionStartedStamp.createdAt, - threadId: input.threadId, - payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {}, - }); - - const threadStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "thread.started", - eventId: threadStartedStamp.eventId, - provider: PROVIDER, - createdAt: threadStartedStamp.createdAt, - threadId: input.threadId, - payload: { - providerThreadId: acpSessionId, - }, - }); - - const readyStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.state.changed", - eventId: readyStamp.eventId, - provider: PROVIDER, - createdAt: readyStamp.createdAt, - threadId: input.threadId, - payload: { - state: "ready", - }, - }); - - return { - ...context.session, - }; - }); - - const sendTurn: CursorAdapterShape["sendTurn"] = (input) => - Effect.gen(function* () { - const context = yield* requireSession(input.threadId); - - if (context.turnState) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: `Thread '${input.threadId}' already has an active turn '${context.turnState.turnId}'.`, - }); - } - - const promptText = input.input?.trim(); - if (!promptText || promptText.length === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Turn input must be non-empty.", - }); - } - - const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); - const turnState: CursorTurnState = { - turnId, - assistantItemId: asProviderItemId(yield* Random.nextUUIDv4), - startedToolCalls: new Set(), - toolCalls: new Map(), - items: [], - }; - - context.turnState = turnState; - context.session = { - ...context.session, - status: "running", - activeTurnId: turnId, - updatedAt: yield* nowIso, - }; - - const startedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.started", - eventId: startedStamp.eventId, - provider: PROVIDER, - createdAt: startedStamp.createdAt, - threadId: context.session.threadId, - turnId, - payload: input.model ? { model: input.model } : {}, - providerRefs: { - providerTurnId: String(turnId), - }, - }); - - const promptResultRaw = yield* Effect.tryPromise({ - try: async () => - sendRequest(context, "session/prompt", { - sessionId: context.acpSessionId, - prompt: [{ type: "text", text: promptText }], - }), - catch: (cause) => toRequestError(input.threadId, "session/prompt", cause), - }); - - const promptResult = yield* Effect.try({ - try: () => Schema.decodeUnknownSync(CursorAcpSessionPromptResult)(promptResultRaw), - catch: (cause) => - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Cursor session/prompt response did not match expected schema.", - cause, - }), - }); - const turnStateValue = mapStopReasonToTurnState(promptResult.stopReason); - yield* completeTurn( - context, - turnStateValue, - turnStateValue === "failed" ? "Cursor prompt failed." : undefined, - promptResult.stopReason, - ); - - context.session = { - ...context.session, - status: "ready", - activeTurnId: undefined, - updatedAt: yield* nowIso, - resumeCursor: { - acpSessionId: context.acpSessionId, - }, - }; - - return { - threadId: context.session.threadId, - turnId, - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), - } satisfies ProviderTurnStartResult; - }); - - const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, _turnId) => - Effect.gen(function* () { - const context = yield* requireSession(threadId); - if (!context.turnState) { - return; - } - - const cancelResult = yield* Effect.tryPromise({ - try: async () => - sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000), - catch: (cause) => toRequestError(threadId, "session/cancel", cause), - }).pipe( - Effect.match({ - onFailure: (error) => ({ ok: false as const, error }), - onSuccess: () => ({ ok: true as const }), - }), - ); - - if (!cancelResult.ok) { - yield* emitRuntimeWarning( - context, - "Cursor ACP session/cancel is unavailable; marking turn as interrupted.", - cancelResult.error, - ); - } - - yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled"); - }); - - const readThread: CursorAdapterShape["readThread"] = (threadId) => - Effect.gen(function* () { - const context = yield* requireSession(threadId); - return { - threadId: context.session.threadId, - turns: context.turns.map((turn) => ({ - id: turn.id, - items: [...turn.items], - })), - }; - }); - - const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, _numTurns) => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread/rollback", - detail: `Cursor ACP does not support thread rollback for thread '${threadId}'.`, - }), - ); - - const respondToRequest: CursorAdapterShape["respondToRequest"] = ( - threadId, - requestId, - decision, - ) => - Effect.gen(function* () { - const context = yield* requireSession(threadId); - const pending = context.pendingPermissions.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `Unknown pending permission request: ${requestId}`, - }); - } - - const optionId = selectCursorPermissionOption(pending.options, decision); - - if (!optionId) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `No selectable permission options for request: ${requestId}`, - }); - } - - writeCursorMessage(context, { - jsonrpc: "2.0", - id: pending.jsonRpcId, - result: { - outcome: { - outcome: "selected", - optionId, - }, - }, - }); - - context.pendingPermissions.delete(requestId); - - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "request.resolved", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...((context.turnState ? { turnId: context.turnState.turnId } : {})), - requestId: asRuntimeRequestId(requestId), - payload: { - requestType: pending.requestType, - decision, - resolution: { - optionId, - }, - }, - providerRefs: { - ...(context.turnState ? { providerTurnId: String(context.turnState.turnId) } : {}), - providerRequestId: String(pending.jsonRpcId), - }, - raw: { - source: "cursor.acp.response", - method: "session/request_permission", - payload: { - optionId, - }, - }, - }); - }); - - const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( - threadId, - requestId, - _answers, - ) => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "cursor/ask_question", - detail: `Cursor does not yet support structured user-input responses for thread '${threadId}' and request '${requestId}'.`, - }), - ); - - const stopSession: CursorAdapterShape["stopSession"] = (threadId) => - Effect.gen(function* () { - const context = yield* requireSession(threadId); - yield* stopSessionInternal(context, { - emitExitEvent: true, - }); - }); - - const listSessions: CursorAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); - - const hasSession: CursorAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => { - const context = sessions.get(threadId); - return context !== undefined && !context.stopping; - }); - - const stopAll: CursorAdapterShape["stopAll"] = () => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: true, - }), - { discard: true }, - ); - - yield* Effect.addFinalizer(() => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: false, - }), - { discard: true }, - ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), - ); - - return { - provider: PROVIDER, - capabilities: { - sessionModelSwitch: "unsupported", - }, - startSession, - sendTurn, - interruptTurn, - readThread, - rollbackThread, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - stopAll, - streamEvents: Stream.fromQueue(runtimeEventQueue), - } satisfies CursorAdapterShape; - }); -} - -export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); - -export function makeCursorAdapterLive(options?: CursorAdapterLiveOptions) { - return Layer.effect(CursorAdapter, makeCursorAdapter(options)); -} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index e44bd746cdfbde7b344ed4e037a6f41f95a95d00..795c106f0551b95b8848de129e400fd91381e869 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -6,7 +6,6 @@ import { Effect, Layer, Stream } from "effect"; import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -46,23 +45,6 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = { streamEvents: Stream.empty, }; -const fakeCursorAdapter: CursorAdapterShape = { - provider: "cursor", - capabilities: { sessionModelSwitch: "unsupported" }, - startSession: vi.fn(), - sendTurn: vi.fn(), - interruptTurn: vi.fn(), - respondToRequest: vi.fn(), - respondToUserInput: vi.fn(), - stopSession: vi.fn(), - listSessions: vi.fn(), - hasSession: vi.fn(), - readThread: vi.fn(), - rollbackThread: vi.fn(), - stopAll: vi.fn(), - streamEvents: Stream.empty, -}; - const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -70,7 +52,6 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter), - Layer.succeed(CursorAdapter, fakeCursorAdapter), ), ), NodeServices.layer, @@ -83,13 +64,11 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeCode"); - const cursor = yield* registry.getByProvider("cursor"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); - assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeCode", "cursor"]); + assert.deepEqual(providers, ["codex", "claudeCode"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 3664e1db18f5cff1d73f6f9fc8f298e735065286..37c430951878ce80bcadb504459b6223801aa301 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,7 +17,6 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; -import { CursorAdapter } from "../Services/CursorAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -28,7 +27,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* CursorAdapter, yield* ClaudeCodeAdapter]; + : [yield* CodexAdapter, yield* ClaudeCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Services/CursorAdapter.test.ts b/apps/server/src/provider/Services/CursorAdapter.test.ts deleted file mode 100644 index 007a212e1577d63bf16b56305ff2151b2d5ad2d5..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Services/CursorAdapter.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - CursorAcpPermissionRequest, - CursorAcpSessionPromptResult, - CursorAcpSessionUpdateNotification, -} from "./CursorAdapter.ts"; - -describe("Cursor ACP schemas", () => { - it("decodes session/update thought and message chunks", () => { - const thought = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "sess-1", - update: { - sessionUpdate: "agent_thought_chunk", - content: { - type: "text", - text: "thinking", - }, - }, - }, - }); - - expect(thought.params.update.sessionUpdate).toBe("agent_thought_chunk"); - - const message = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "sess-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "hello", - }, - }, - }, - }); - - expect(message.params.update.sessionUpdate).toBe("agent_message_chunk"); - }); - - it("decodes tool call lifecycle updates", () => { - const started = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "sess-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-1", - title: "Terminal", - kind: "execute", - status: "pending", - rawInput: { command: "pwd" }, - }, - }, - }); - - expect(started.params.update.sessionUpdate).toBe("tool_call"); - - const completed = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "sess-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - status: "completed", - rawOutput: { - exitCode: 0, - stdout: "ok", - stderr: "", - }, - }, - }, - }); - - expect(completed.params.update.sessionUpdate).toBe("tool_call_update"); - }); - - it("decodes permission requests", () => { - const decoded = Schema.decodeUnknownSync(CursorAcpPermissionRequest)({ - jsonrpc: "2.0", - id: 9, - method: "session/request_permission", - params: { - sessionId: "sess-1", - toolCall: { - toolCallId: "tool-1", - kind: "execute", - }, - options: [ - { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, - { optionId: "reject-once", name: "Reject", kind: "reject_once" }, - ], - }, - }); - - expect(decoded.method).toBe("session/request_permission"); - expect(decoded.params.options).toHaveLength(2); - }); - - it("decodes prompt completion result payload", () => { - const decoded = Schema.decodeUnknownSync(CursorAcpSessionPromptResult)({ - stopReason: "end_turn", - }); - - expect(decoded.stopReason).toBe("end_turn"); - }); - - it("rejects unsupported update types", () => { - expect(() => - Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "sess-1", - update: { - sessionUpdate: "unknown_update", - }, - }, - }), - ).toThrow(); - }); -}); diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts deleted file mode 100644 index 405ea3689b6755a780c88dc35053ed6023d7fbee..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Services/CursorAdapter.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * CursorAdapter - Cursor ACP implementation of the generic provider adapter contract. - * - * Defines ACP JSON-RPC schemas used by the Cursor adapter layer. - * - * @module CursorAdapter - */ -import { Schema, ServiceMap } from "effect"; - -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -export const CursorAcpJsonRpcId = Schema.Union([Schema.String, Schema.Int]); -export type CursorAcpJsonRpcId = typeof CursorAcpJsonRpcId.Type; - -export const CursorAcpTextContent = Schema.Struct({ - type: Schema.Literal("text"), - text: Schema.String, -}); -export type CursorAcpTextContent = typeof CursorAcpTextContent.Type; - -export const CursorAcpSessionUpdate = Schema.Union([ - Schema.Struct({ - sessionUpdate: Schema.Literal("available_commands_update"), - availableCommands: Schema.Array( - Schema.Struct({ - name: Schema.String, - description: Schema.optional(Schema.String), - }), - ), - }), - Schema.Struct({ - sessionUpdate: Schema.Literal("agent_thought_chunk"), - content: CursorAcpTextContent, - }), - Schema.Struct({ - sessionUpdate: Schema.Literal("agent_message_chunk"), - content: CursorAcpTextContent, - }), - Schema.Struct({ - sessionUpdate: Schema.Literal("tool_call"), - toolCallId: Schema.String, - title: Schema.optional(Schema.String), - kind: Schema.optional(Schema.String), - status: Schema.optional(Schema.String), - rawInput: Schema.optional(Schema.Unknown), - }), - Schema.Struct({ - sessionUpdate: Schema.Literal("tool_call_update"), - toolCallId: Schema.String, - status: Schema.String, - rawOutput: Schema.optional(Schema.Unknown), - }), -]); -export type CursorAcpSessionUpdate = typeof CursorAcpSessionUpdate.Type; - -export const CursorAcpSessionUpdateNotification = Schema.Struct({ - jsonrpc: Schema.optional(Schema.Literal("2.0")), - method: Schema.Literal("session/update"), - params: Schema.Struct({ - sessionId: Schema.String, - update: CursorAcpSessionUpdate, - }), -}); -export type CursorAcpSessionUpdateNotification = typeof CursorAcpSessionUpdateNotification.Type; - -export const CursorAcpPermissionOption = Schema.Struct({ - optionId: Schema.String, - name: Schema.optional(Schema.String), - kind: Schema.optional(Schema.String), -}); -export type CursorAcpPermissionOption = typeof CursorAcpPermissionOption.Type; - -export const CursorAcpPermissionRequest = Schema.Struct({ - jsonrpc: Schema.optional(Schema.Literal("2.0")), - id: CursorAcpJsonRpcId, - method: Schema.Literal("session/request_permission"), - params: Schema.Struct({ - sessionId: Schema.String, - toolCall: Schema.optional(Schema.Unknown), - options: Schema.Array(CursorAcpPermissionOption), - }), -}); -export type CursorAcpPermissionRequest = typeof CursorAcpPermissionRequest.Type; - -export const CursorAcpInitializeResult = Schema.Struct({ - protocolVersion: Schema.optional(Schema.Int), - agentCapabilities: Schema.optional(Schema.Unknown), - authMethods: Schema.optional(Schema.Array(Schema.Unknown)), -}); -export type CursorAcpInitializeResult = typeof CursorAcpInitializeResult.Type; - -export const CursorAcpSessionNewResult = Schema.Struct({ - sessionId: Schema.String, - modes: Schema.optional(Schema.Unknown), -}); -export type CursorAcpSessionNewResult = typeof CursorAcpSessionNewResult.Type; - -export const CursorAcpSessionPromptResult = Schema.Struct({ - stopReason: Schema.optional(Schema.String), -}); -export type CursorAcpSessionPromptResult = typeof CursorAcpSessionPromptResult.Type; - -export interface CursorAdapterShape extends ProviderAdapterShape { - readonly provider: "cursor"; -} - -export class CursorAdapter extends ServiceMap.Service()( - "t3/provider/Services/CursorAdapter", -) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ae5249df894652df026d7b8945f04ee08df6e453..32dd79fea65b686c7e38103fefbbd6f98c09f134 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -20,7 +20,6 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; -import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -61,13 +60,9 @@ export function makeServerProviderLayer(): Layer.Layer< const claudeAdapterLayer = makeClaudeCodeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); - const cursorAdapterLayer = makeCursorAdapterLive( - nativeEventLogger ? { nativeEventLogger } : undefined, - ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), - Layer.provide(cursorAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index af1c6cc8a8bd1a2e1431b21c63f24eb53046277b..4221bb9148129d34b3a02597d2703f09705dd707 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -54,13 +54,6 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-claude-model-slug", example: "claude-sonnet-5-0", }, - { - provider: "cursor", - title: "Cursor", - description: "Save additional Cursor model slugs for the picker and `/model` command.", - placeholder: "your-cursor-model-slug", - example: "openai/gpt-oss-120b", - }, ] as const; function getCustomModelsForProvider( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index eebf1170493c1076b124673bc755b1ad800402fe..f161384db83f70296d903788afdf3fdca53256cd 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -523,12 +523,8 @@ describe("PROVIDER_OPTIONS", () => { }); }); - it("marks cursor as available", () => { + it("does not advertise cursor as an available provider", () => { const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); - expect(cursor).toEqual({ - value: "cursor", - label: "Cursor Agent", - available: true, - }); + expect(cursor).toBeUndefined(); }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 2ded1b984b15bdbc8133084ecf69dd50461e84f8..36213bb6c5dcb52967b6104b6ca82689689db049 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -17,7 +17,6 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeCode", label: "Claude Code", available: true }, - { value: "cursor", label: "Cursor Agent", available: true }, ]; export interface WorkLogEntry {