1566 lines · 48.8 KB · TypeScript
RawDownload
/** * 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<typeof setTimeout>;  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<typeof ProviderItemId.makeUnsafe>;  readonly startedToolCalls: Set<string>;  readonly toolCalls: Map<string, { itemType: CanonicalItemType; title: string }>;  readonly items: Array<unknown>;} interface CursorSessionContext {  session: ProviderSession;  runtimeMode: RuntimeMode;  readonly child: ChildProcessWithoutNullStreams;  readonly output: readline.Interface;  readonly pending: Map<string, PendingRequest>;  readonly pendingPermissions: Map<ApprovalRequestId, PendingPermission>;  readonly turns: Array<{    id: TurnId;    items: Array<unknown>;  }>;  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<string, unknown> | undefined {  if (!value || typeof value !== "object") {    return undefined;  }  return value as Record<string, unknown>;} 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<ThreadId, CursorSessionContext>();    const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();     const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>      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<unknown> => {      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<string, unknown>,    ) => {      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<void> =>      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<void> =>      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<void> => {      let decoded: ReturnType<typeof decodePermissionRequest>;

Lines 1–500 of 1566.