/** * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP. * * @module CursorAdapterLive */ import { ApprovalRequestId, type CursorSettings, type ProviderOptionSelection, EventId, type ProviderApprovalDecision, type ProviderInteractionMode, type ProviderRuntimeEvent, type ProviderSession, type ProviderUserInputAnswers, ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, type RuntimeMode, type ThreadId, TurnId,} from "@t3tools/contracts";import * as DateTime from "effect/DateTime";import * as Crypto from "effect/Crypto";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as Fiber from "effect/Fiber";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as PubSub from "effect/PubSub";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream";import * as SynchronizedRef from "effect/SynchronizedRef";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";import * as EffectAcpErrors from "effect-acp/errors";import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts";import { ServerConfig } from "../../config.ts";import * as McpProviderSession from "../../mcp/McpProviderSession.ts";import { ProviderAdapterProcessError, ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError,} from "../Errors.ts";import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts";import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, makeAcpPlanUpdatedEvent, makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, makeAcpToolCallEvent,} from "../acp/AcpCoreRuntimeEvents.ts";import { type AcpSessionMode, type AcpSessionModeState, parsePermissionRequest,} from "../acp/AcpRuntimeModel.ts";import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts";import { CursorAskQuestionRequest, CursorCreatePlanRequest, CursorUpdateTodosRequest, extractAskQuestions, extractPlanMarkdown, extractTodosAsPlan,} from "../acp/CursorAcpExtension.ts";import { type CursorAdapterShape } from "../Services/CursorAdapter.ts";import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const PROVIDER = ProviderDriverKind.make("cursor");const CURSOR_RESUME_VERSION = 1 as const;const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];const ACP_APPROVAL_MODE_ALIASES = ["ask"]; function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); return Exit.isSuccess(result) ? result.value : undefined;} export interface CursorAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; /** * Selections are honored when `modelSelection.instanceId` matches this value. * Defaults to the legacy built-in instance id (`cursor`). */ readonly instanceId?: ProviderInstanceId; /** * Optional per-session settings resolver. When provided the adapter yields * this effect at the start of every session and uses the result instead of * the `cursorSettings` captured at construction. * * Production instances bind settings to the instance scope (the hydration * layer rebuilds the adapter on config change) and leave this undefined. * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads * the latest snapshot so the closure isn't stale. */ readonly resolveSettings?: Effect.Effect<CursorSettings>;} interface PendingApproval { readonly decision: Deferred.Deferred<ProviderApprovalDecision>; readonly kind: string | "unknown";} interface PendingUserInput { readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;} interface CursorSessionContext { readonly threadId: ThreadId; session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; notificationFiber: Fiber.Fiber<void, never> | undefined; readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>; readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>; readonly turns: Array<{ id: TurnId; items: Array<unknown> }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; stopped: boolean;} function settlePendingApprovalsAsCancelled( pendingApprovals: ReadonlyMap<ApprovalRequestId, PendingApproval>,): Effect.Effect<void> { const pendingEntries = Array.from(pendingApprovals.values()); return Effect.forEach( pendingEntries, (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), { discard: true, }, );} function settlePendingUserInputsAsEmptyAnswers( pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>,): Effect.Effect<void> { const pendingEntries = Array.from(pendingUserInputs.values()); return Effect.forEach( pendingEntries, (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), { discard: true, }, );} function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value);} function parseCursorResume(raw: unknown): { sessionId: string } | undefined { if (!isRecord(raw)) return undefined; if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined; if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; return { sessionId: raw.sessionId.trim() };} function normalizeModeSearchText(mode: AcpSessionMode): string { return [mode.id, mode.name, mode.description] .filter((value): value is string => typeof value === "string" && value.length > 0) .join(" ") .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .trim();} function findModeByAliases( modes: ReadonlyArray<AcpSessionMode>, aliases: ReadonlyArray<string>,): AcpSessionMode | undefined { const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); for (const alias of normalizedAliases) { const exact = modes.find((mode) => { const id = mode.id.toLowerCase(); const name = mode.name.toLowerCase(); return id === alias || name === alias; }); if (exact) { return exact; } } for (const alias of normalizedAliases) { const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); if (partial) { return partial; } } return undefined;} function isPlanMode(mode: AcpSessionMode): boolean { return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined;} function resolveRequestedModeId(input: { readonly interactionMode: ProviderInteractionMode | undefined; readonly runtimeMode: RuntimeMode; readonly modeState: AcpSessionModeState | undefined;}): string | undefined { const modeState = input.modeState; if (!modeState) { return undefined; } if (input.interactionMode === "plan") { return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; } if (input.runtimeMode === "approval-required") { return ( findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? modeState.currentModeId ); } return ( findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? modeState.currentModeId );} function applyRequestedSessionConfiguration<E>(input: { readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode | undefined; readonly modelSelection: | { readonly model: string; readonly options?: ReadonlyArray<ProviderOptionSelection> | null | undefined; } | undefined; readonly mapError: (context: { readonly cause: import("effect-acp/errors").AcpError; readonly method: "session/set_config_option" | "session/set_mode"; }) => E;}): Effect.Effect<void, E> { return Effect.gen(function* () { if (input.modelSelection) { yield* applyCursorAcpModelSelection({ runtime: input.runtime, model: input.modelSelection.model, selections: input.modelSelection.options, mapError: ({ cause }) => input.mapError({ cause, method: "session/set_config_option", }), }); } const requestedModeId = resolveRequestedModeId({ interactionMode: input.interactionMode, runtimeMode: input.runtimeMode, modeState: yield* input.runtime.getModeState, }); if (!requestedModeId) { return; } yield* input.runtime.setMode(requestedModeId).pipe( Effect.mapError((cause) => input.mapError({ cause, method: "session/set_mode", }), ), ); });} function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest,): string | undefined { const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { return allowAlwaysOption.optionId.trim(); } const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { return allowOnceOption.optionId.trim(); } return undefined;} export function makeCursorAdapter( cursorSettings: CursorSettings, options?: CursorAdapterLiveOptions,) { return Effect.gen(function* () { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); const crypto = yield* Crypto.Crypto; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native", }) : undefined); const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); const sessions = new Map<ThreadId, CursorSessionContext>(); const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>()); const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => new ProviderAdapterRequestError({ provider: PROVIDER, method: "crypto/randomUUIDv4", detail: "Failed to generate Cursor runtime identifier.", cause, }), ), ); const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); const mapExtensionFailure = <A, E, R>(effect: Effect.Effect<A, E, R>) => effect.pipe( Effect.mapError( (cause) => new EffectAcpErrors.AcpTransportError({ detail: "Failed to process Cursor ACP extension event.", cause, }), ), ); const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); const getThreadSemaphore = (threadId: string) => SynchronizedRef.modifyEffect(threadLocksRef, (current) => { const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr( current.get(threadId), ); return Option.match(existing, { onNone: () => Semaphore.make(1).pipe( Effect.map((semaphore) => { const next = new Map(current); next.set(threadId, semaphore); return [semaphore, next] as const; }), ), onSome: (semaphore) => Effect.succeed([semaphore, current] as const), }); }); const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) => Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); const logNative = ( threadId: ThreadId, method: string, payload: unknown, _source: "acp.jsonrpc" | "acp.cursor.extension", ) => Effect.gen(function* () { if (!nativeEventLogger) return; const observedAt = yield* nowIso; yield* nativeEventLogger.write( { observedAt, event: { id: yield* randomUUIDv4, kind: "notification", provider: PROVIDER, createdAt: observedAt, method, threadId, payload, }, }, threadId, ); }); const emitPlanUpdate = ( ctx: CursorSessionContext, payload: { readonly explanation?: string | null; readonly plan: ReadonlyArray<{ readonly step: string; readonly status: "pending" | "inProgress" | "completed"; }>; }, rawPayload: unknown, source: "acp.jsonrpc" | "acp.cursor.extension", method: string, ) => Effect.gen(function* () { const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; if (ctx.lastPlanFingerprint === fingerprint) { return; } ctx.lastPlanFingerprint = fingerprint; yield* offerRuntimeEvent( makeAcpPlanUpdatedEvent({ stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, payload, source, method, rawPayload, }), ); }); const requireSession = ( threadId: ThreadId, ): Effect.Effect<CursorSessionContext, ProviderAdapterSessionNotFoundError> => { const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), ); } return Effect.succeed(ctx); }; const stopSessionInternal = (ctx: CursorSessionContext) => Effect.gen(function* () { if (ctx.stopped) return; ctx.stopped = true; yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); sessions.delete(ctx.threadId); yield* offerRuntimeEvent({ type: "session.exited", ...(yield* makeEventStamp()), provider: PROVIDER, threadId: ctx.threadId, payload: { exitKind: "graceful" }, }); }); const startSession: CursorAdapterShape["startSession"] = (input) => withThreadLock( input.threadId, 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}'.`, }); } if (!input.cwd?.trim()) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "startSession", issue: "cwd is required and must be non-empty.", }); } const cwd = path.resolve(input.cwd.trim()); const cursorModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;Lines 1–500 of 1182. Load more
/** * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP. * * @module CursorAdapterLive */ import { ApprovalRequestId, type CursorSettings, type ProviderOptionSelection, EventId, type ProviderApprovalDecision, type ProviderInteractionMode, type ProviderRuntimeEvent, type ProviderSession, type ProviderUserInputAnswers, ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, type RuntimeMode, type ThreadId, TurnId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, makeAcpPlanUpdatedEvent, makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, makeAcpToolCallEvent, } from "../acp/AcpCoreRuntimeEvents.ts"; import { type AcpSessionMode, type AcpSessionModeState, parsePermissionRequest, } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; import { CursorAskQuestionRequest, CursorCreatePlanRequest, CursorUpdateTodosRequest, extractAskQuestions, extractPlanMarkdown, extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const PROVIDER = ProviderDriverKind.make("cursor"); const CURSOR_RESUME_VERSION = 1 as const; const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; const ACP_APPROVAL_MODE_ALIASES = ["ask"]; function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); return Exit.isSuccess(result) ? result.value : undefined; } export interface CursorAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; /** * Selections are honored when `modelSelection.instanceId` matches this value. * Defaults to the legacy built-in instance id (`cursor`). */ readonly instanceId?: ProviderInstanceId; /** * Optional per-session settings resolver. When provided the adapter yields * this effect at the start of every session and uses the result instead of * the `cursorSettings` captured at construction. * * Production instances bind settings to the instance scope (the hydration * layer rebuilds the adapter on config change) and leave this undefined. * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads * the latest snapshot so the closure isn't stale. */ readonly resolveSettings?: Effect.Effect<CursorSettings>; } interface PendingApproval { readonly decision: Deferred.Deferred<ProviderApprovalDecision>; readonly kind: string | "unknown"; } interface PendingUserInput { readonly answers: Deferred.Deferred<ProviderUserInputAnswers>; } interface CursorSessionContext { readonly threadId: ThreadId; session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; notificationFiber: Fiber.Fiber<void, never> | undefined; readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>; readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>; readonly turns: Array<{ id: TurnId; items: Array<unknown> }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; stopped: boolean; } function settlePendingApprovalsAsCancelled( pendingApprovals: ReadonlyMap<ApprovalRequestId, PendingApproval>, ): Effect.Effect<void> { const pendingEntries = Array.from(pendingApprovals.values()); return Effect.forEach( pendingEntries, (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), { discard: true, }, ); } function settlePendingUserInputsAsEmptyAnswers( pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>, ): Effect.Effect<void> { const pendingEntries = Array.from(pendingUserInputs.values()); return Effect.forEach( pendingEntries, (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), { discard: true, }, ); } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseCursorResume(raw: unknown): { sessionId: string } | undefined { if (!isRecord(raw)) return undefined; if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined; if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; return { sessionId: raw.sessionId.trim() }; } function normalizeModeSearchText(mode: AcpSessionMode): string { return [mode.id, mode.name, mode.description] .filter((value): value is string => typeof value === "string" && value.length > 0) .join(" ") .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .trim(); } function findModeByAliases( modes: ReadonlyArray<AcpSessionMode>, aliases: ReadonlyArray<string>, ): AcpSessionMode | undefined { const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); for (const alias of normalizedAliases) { const exact = modes.find((mode) => { const id = mode.id.toLowerCase(); const name = mode.name.toLowerCase(); return id === alias || name === alias; }); if (exact) { return exact; } } for (const alias of normalizedAliases) { const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); if (partial) { return partial; } } return undefined; } function isPlanMode(mode: AcpSessionMode): boolean { return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; } function resolveRequestedModeId(input: { readonly interactionMode: ProviderInteractionMode | undefined; readonly runtimeMode: RuntimeMode; readonly modeState: AcpSessionModeState | undefined; }): string | undefined { const modeState = input.modeState; if (!modeState) { return undefined; } if (input.interactionMode === "plan") { return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; } if (input.runtimeMode === "approval-required") { return ( findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? modeState.currentModeId ); } return ( findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? modeState.currentModeId ); } function applyRequestedSessionConfiguration<E>(input: { readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode | undefined; readonly modelSelection: | { readonly model: string; readonly options?: ReadonlyArray<ProviderOptionSelection> | null | undefined; } | undefined; readonly mapError: (context: { readonly cause: import("effect-acp/errors").AcpError; readonly method: "session/set_config_option" | "session/set_mode"; }) => E; }): Effect.Effect<void, E> { return Effect.gen(function* () { if (input.modelSelection) { yield* applyCursorAcpModelSelection({ runtime: input.runtime, model: input.modelSelection.model, selections: input.modelSelection.options, mapError: ({ cause }) => input.mapError({ cause, method: "session/set_config_option", }), }); } const requestedModeId = resolveRequestedModeId({ interactionMode: input.interactionMode, runtimeMode: input.runtimeMode, modeState: yield* input.runtime.getModeState, }); if (!requestedModeId) { return; } yield* input.runtime.setMode(requestedModeId).pipe( Effect.mapError((cause) => input.mapError({ cause, method: "session/set_mode", }), ), ); }); } function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { return allowAlwaysOption.optionId.trim(); } const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { return allowOnceOption.optionId.trim(); } return undefined; } export function makeCursorAdapter( cursorSettings: CursorSettings, options?: CursorAdapterLiveOptions, ) { return Effect.gen(function* () { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); const crypto = yield* Crypto.Crypto; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native", }) : undefined); const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); const sessions = new Map<ThreadId, CursorSessionContext>(); const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>()); const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => new ProviderAdapterRequestError({ provider: PROVIDER, method: "crypto/randomUUIDv4", detail: "Failed to generate Cursor runtime identifier.", cause, }), ), ); const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); const mapExtensionFailure = <A, E, R>(effect: Effect.Effect<A, E, R>) => effect.pipe( Effect.mapError( (cause) => new EffectAcpErrors.AcpTransportError({ detail: "Failed to process Cursor ACP extension event.", cause, }), ), ); const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); const getThreadSemaphore = (threadId: string) => SynchronizedRef.modifyEffect(threadLocksRef, (current) => { const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr( current.get(threadId), ); return Option.match(existing, { onNone: () => Semaphore.make(1).pipe( Effect.map((semaphore) => { const next = new Map(current); next.set(threadId, semaphore); return [semaphore, next] as const; }), ), onSome: (semaphore) => Effect.succeed([semaphore, current] as const), }); }); const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) => Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); const logNative = ( threadId: ThreadId, method: string, payload: unknown, _source: "acp.jsonrpc" | "acp.cursor.extension", ) => Effect.gen(function* () { if (!nativeEventLogger) return; const observedAt = yield* nowIso; yield* nativeEventLogger.write( { observedAt, event: { id: yield* randomUUIDv4, kind: "notification", provider: PROVIDER, createdAt: observedAt, method, threadId, payload, }, }, threadId, ); }); const emitPlanUpdate = ( ctx: CursorSessionContext, payload: { readonly explanation?: string | null; readonly plan: ReadonlyArray<{ readonly step: string; readonly status: "pending" | "inProgress" | "completed"; }>; }, rawPayload: unknown, source: "acp.jsonrpc" | "acp.cursor.extension", method: string, ) => Effect.gen(function* () { const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; if (ctx.lastPlanFingerprint === fingerprint) { return; } ctx.lastPlanFingerprint = fingerprint; yield* offerRuntimeEvent( makeAcpPlanUpdatedEvent({ stamp: yield* makeEventStamp(), provider: PROVIDER, threadId: ctx.threadId, turnId: ctx.activeTurnId, payload, source, method, rawPayload, }), ); }); const requireSession = ( threadId: ThreadId, ): Effect.Effect<CursorSessionContext, ProviderAdapterSessionNotFoundError> => { const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), ); } return Effect.succeed(ctx); }; const stopSessionInternal = (ctx: CursorSessionContext) => Effect.gen(function* () { if (ctx.stopped) return; ctx.stopped = true; yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); sessions.delete(ctx.threadId); yield* offerRuntimeEvent({ type: "session.exited", ...(yield* makeEventStamp()), provider: PROVIDER, threadId: ctx.threadId, payload: { exitKind: "graceful" }, }); }); const startSession: CursorAdapterShape["startSession"] = (input) => withThreadLock( input.threadId, 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}'.`, }); } if (!input.cwd?.trim()) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "startSession", issue: "cwd is required and must be non-empty.", }); } const cwd = path.resolve(input.cwd.trim()); const cursorModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;