1182 lines · 42.9 KB · TypeScript
RawDownload
/** * 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.