From 0255041ab71df0060cb2681ebbb626f59bc8d121 Mon Sep 17 00:00:00 2001 From: Gilad Leifman Date: Sun, 8 Mar 2026 14:34:13 -0400 Subject: [PATCH] Add Cursor CLI provider support --- .../Layers/ProviderCommandReactor.ts | 18 +- .../Layers/ProviderRuntimeIngestion.test.ts | 3 +- apps/server/src/orchestration/decider.ts | 3 + .../src/provider/Layers/CursorAdapter.test.ts | 70 ++ .../src/provider/Layers/CursorAdapter.ts | 817 ++++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 28 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../provider/Layers/ProviderHealth.test.ts | 50 +- .../src/provider/Layers/ProviderHealth.ts | 87 +- .../Layers/ProviderSessionDirectory.ts | 20 +- .../src/provider/Services/CursorAdapter.ts | 12 + apps/server/src/provider/cursorCli.ts | 7 + apps/server/src/serverLayers.ts | 3 + apps/web/src/appSettings.ts | 8 + apps/web/src/components/ChatView.tsx | 46 +- apps/web/src/composerDraftStore.ts | 5 +- apps/web/src/routes/_chat.settings.tsx | 63 +- apps/web/src/session-logic.ts | 2 +- apps/web/src/store.ts | 9 +- packages/contracts/src/model.ts | 7 + packages/contracts/src/orchestration.test.ts | 26 + packages/contracts/src/orchestration.ts | 47 +- packages/contracts/src/provider.test.ts | 18 + packages/contracts/src/provider.ts | 5 + packages/shared/src/model.test.ts | 6 + packages/shared/src/model.ts | 1 + 26 files changed, 1325 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/provider/Layers/CursorAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapter.ts create mode 100644 apps/server/src/provider/Services/CursorAdapter.ts create mode 100644 apps/server/src/provider/cursorCli.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d34791bc20..3c79731f1e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -3,8 +3,9 @@ import { CommandId, EventId, type OrchestrationEvent, + ProviderKind, + ProviderSessionStartInput, type ProviderModelOptions, - type ProviderKind, type ProviderServiceTier, type OrchestrationSession, ThreadId, @@ -203,6 +204,7 @@ const make = Effect.gen(function* () { readonly model?: string; readonly modelOptions?: ProviderModelOptions; readonly serviceTier?: ProviderServiceTier | null; + readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -213,7 +215,11 @@ const make = Effect.gen(function* () { const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = - thread.session?.providerName === "codex" ? thread.session.providerName : undefined; + thread.session?.providerName !== null && + thread.session?.providerName !== undefined && + Schema.is(ProviderKind)(thread.session.providerName) + ? thread.session.providerName + : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ @@ -239,6 +245,9 @@ const make = Effect.gen(function* () { ...(desiredModel ? { model: desiredModel } : {}), ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), + ...(options?.providerOptions !== undefined + ? { providerOptions: options.providerOptions } + : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -325,6 +334,7 @@ const make = Effect.gen(function* () { readonly model?: string; readonly serviceTier?: ProviderServiceTier | null; readonly modelOptions?: ProviderModelOptions; + readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -337,6 +347,7 @@ const make = Effect.gen(function* () { ...(input.model !== undefined ? { model: input.model } : {}), ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -472,6 +483,9 @@ const make = Effect.gen(function* () { ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), + ...(event.payload.providerOptions !== undefined + ? { providerOptions: event.payload.providerOptions } + : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 96242b846c..c73c01a4f9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -10,6 +10,7 @@ import { EventId, MessageId, ProjectId, + type ProviderKind, ProviderItemId, ThreadId, TurnId, @@ -45,7 +46,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: ProviderKind; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f036416429..47bfc3d927 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -303,6 +303,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.model !== undefined ? { model: command.model } : {}), ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), + ...(command.providerOptions !== undefined + ? { providerOptions: command.providerOptions } + : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts new file mode 100644 index 0000000000..3b8fc87cb5 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,70 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { type ChildProcess, spawn } from "node:child_process"; + +import { ThreadId } from "@t3tools/contracts"; +import { it, assert } from "@effect/vitest"; +import { Effect, Stream } from "effect"; + +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import { makeCursorAdapterLive } from "./CursorAdapter.ts"; + +function makeFakeSpawn(): typeof spawn { + return ((command: string) => { + assert.equal(command.includes("cursor"), true); + + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as ChildProcess; + child.stdout = stdout; + child.stderr = stderr; + child.stdin = null; + child.kill = () => true; + + queueMicrotask(() => { + stdout.write('{"type":"assistant","message":{"content":[{"text":"Hello from Cursor"}]}}\n'); + stdout.write('{"type":"result","result":"Hello from Cursor","session_id":"sess-cursor-1"}\n'); + stdout.end(); + child.emit("close", 0, null); + }); + + return child; + }) as typeof spawn; +} + +const layer = it.layer(makeCursorAdapterLive({ spawnProcess: makeFakeSpawn() })); + +layer("CursorAdapterLive", (it) => { + it.effect("starts a session and maps stream-json output into runtime events", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const threadId = ThreadId.makeUnsafe("thread-cursor"); + + const session = yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + model: "gpt-5", + runtimeMode: "full-access", + }); + assert.equal(session.provider, "cursor"); + assert.equal(session.status, "ready"); + + yield* adapter.sendTurn({ + threadId, + input: "Say hello", + interactionMode: "default", + }); + + const events = yield* Stream.take(adapter.streamEvents, 5).pipe(Stream.runCollect); + const eventTypes = Array.from(events).map((event) => event.type); + assert.deepEqual(eventTypes, [ + "session.started", + "session.state.changed", + "turn.started", + "content.delta", + "turn.completed", + ]); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 0000000000..c8e95176ef --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,817 @@ +import { + type ChildProcess as ChildProcessHandle, + spawn, + spawnSync, +} from "node:child_process"; + +import { + EventId, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderTurnStartResult, + RuntimeItemId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, Schema, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { CURSOR_PROVIDER, resolveCursorBinaryPath } from "../cursorCli.ts"; +import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; + +const PROVIDER = CURSOR_PROVIDER; + +type CursorInteractionMode = ProviderSendTurnInput["interactionMode"]; + +export interface CursorAdapterLiveOptions { + readonly spawnProcess?: typeof spawn; +} + +interface ActiveTurnState { + readonly child: ChildProcessHandle; + readonly turnId: TurnId; + readonly interactionMode: CursorInteractionMode; + interrupted: boolean; + assistantText: string; + assistantTextEmitted: boolean; + finalText?: string; + inFlightToolItemIds: Set; +} + +interface CursorSessionState { + readonly createdAt: string; + binaryPath: string; + status: ProviderSession["status"]; + runtimeMode: ProviderSession["runtimeMode"]; + threadId: ThreadId; + cwd?: string | undefined; + model?: string | undefined; + resumeCursor?: string | undefined; + updatedAt: string; + lastError?: string | undefined; + activeTurn?: ActiveTurnState | undefined; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message; + } + return fallback; +} + +function normalizeResumeCursor(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + for (const key of ["sessionId", "session_id", "resumeCursor", "resume_cursor"] as const) { + const candidate = record[key]; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return undefined; +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function extractText(value: unknown): string | undefined { + const direct = asTrimmedString(value); + if (direct) { + return direct; + } + + if (Array.isArray(value)) { + const parts = value + .map((entry) => extractText(entry)) + .filter((entry): entry is string => entry !== undefined); + return parts.length > 0 ? parts.join("") : undefined; + } + + const record = asRecord(value); + if (!record) { + return undefined; + } + + for (const key of ["text", "content", "message", "delta", "result", "output"] as const) { + const nested = extractText(record[key]); + if (nested) { + return nested; + } + } + + return undefined; +} + +function extractCursorSessionId(payload: Record): string | undefined { + return ( + asTrimmedString(payload.session_id) ?? + asTrimmedString(payload.sessionId) ?? + asTrimmedString(asRecord(payload.session)?.id) + ); +} + +function toolItemType(name: string | undefined): + | "command_execution" + | "file_change" + | "web_search" + | "image_view" + | "dynamic_tool_call" { + const normalized = name?.trim().toLowerCase() ?? ""; + if ( + normalized.includes("command") || + normalized.includes("terminal") || + normalized.includes("shell") || + normalized.includes("bash") + ) { + return "command_execution"; + } + if ( + normalized.includes("write") || + normalized.includes("edit") || + normalized.includes("patch") || + normalized.includes("file") + ) { + return "file_change"; + } + if (normalized.includes("search") || normalized.includes("web")) { + return "web_search"; + } + if (normalized.includes("image")) { + return "image_view"; + } + return "dynamic_tool_call"; +} + +function toolDetail(payload: Record): string | undefined { + const toolCall = asRecord(payload.toolCall) ?? asRecord(payload.tool_call); + const args = asRecord(toolCall?.args) ?? asRecord(payload.args); + const command = + extractText(args?.command) ?? extractText(args?.cmd) ?? extractText(args?.argv) ?? undefined; + if (command) { + return command; + } + return ( + extractText(args?.path) ?? + extractText(args?.query) ?? + extractText(toolCall?.name) ?? + extractText(payload.name) + ); +} + +function toolItemId(payload: Record, fallbackTurnId: TurnId): string { + return ( + asTrimmedString(payload.toolCallId) ?? + asTrimmedString(payload.tool_call_id) ?? + asTrimmedString(asRecord(payload.toolCall)?.id) ?? + asTrimmedString(payload.id) ?? + `cursor-tool:${fallbackTurnId}:${crypto.randomUUID()}` + ); +} + +function buildCursorPrompt(input: string, interactionMode: CursorInteractionMode): string { + if (interactionMode !== "plan") { + return input; + } + return [ + "Plan mode: do not make changes or run write actions.", + "Return a concise implementation plan in markdown only.", + "", + input, + ].join("\n"); +} + +function buildCursorArgs(input: { + readonly prompt: string; + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly model?: string; + readonly resumeCursor?: string; +}): string[] { + return [ + "-p", + input.prompt, + "--output-format", + "stream-json", + ...(input.model ? ["-m", input.model] : []), + ...(input.resumeCursor ? ["--resume", input.resumeCursor] : []), + ...(input.runtimeMode === "full-access" ? ["--force"] : []), + ]; +} + +function killChild(child: ChildProcessHandle, signal: NodeJS.Signals = "SIGTERM"): void { + if (process.platform === "win32" && child.pid !== undefined) { + try { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + return; + } catch { + // Fall back to direct kill. + } + } + child.kill(signal); +} + +function attachLineReader(stream: NodeJS.ReadableStream | null, onLine: (line: string) => void) { + if (!stream) { + return; + } + let buffer = ""; + stream.on("data", (chunk: Buffer | string) => { + buffer += chunk.toString(); + while (true) { + const lineBreakIndex = buffer.indexOf("\n"); + if (lineBreakIndex < 0) { + break; + } + const line = buffer.slice(0, lineBreakIndex).trim(); + buffer = buffer.slice(lineBreakIndex + 1); + if (line.length > 0) { + onLine(line); + } + } + }); + stream.on("end", () => { + const trailing = buffer.trim(); + if (trailing.length > 0) { + onLine(trailing); + } + }); +} + +function toProviderSession(session: CursorSessionState): ProviderSession { + return { + provider: PROVIDER, + status: session.status, + runtimeMode: session.runtimeMode, + ...(session.cwd ? { cwd: session.cwd } : {}), + ...(session.model ? { model: session.model } : {}), + threadId: session.threadId, + ...(session.resumeCursor ? { resumeCursor: session.resumeCursor } : {}), + ...(session.activeTurn ? { activeTurnId: session.activeTurn.turnId } : {}), + createdAt: session.createdAt, + updatedAt: session.updatedAt, + ...(session.lastError ? { lastError: session.lastError } : {}), + }; +} + +const makeCursorAdapter = (options?: CursorAdapterLiveOptions) => + Effect.gen(function* () { + const spawnProcess = options?.spawnProcess ?? spawn; + const services = yield* Effect.services(); + const runtimeEventQueue = yield* Queue.unbounded(); + const sessions = new Map(); + + const emit = (event: ProviderRuntimeEvent): void => { + void Queue.offer(runtimeEventQueue, event) + .pipe(Effect.asVoid, Effect.runPromiseWith(services)) + .catch(() => undefined); + }; + + const baseEvent = (threadId: ThreadId, turnId?: TurnId) => ({ + eventId: EventId.makeUnsafe(crypto.randomUUID()), + provider: PROVIDER, + threadId, + createdAt: nowIso(), + ...(turnId ? { turnId } : {}), + }); + + const requireSession = (threadId: ThreadId): CursorSessionState => { + const session = sessions.get(threadId); + if (!session) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return session; + }; + + const startSession: CursorAdapterShape["startSession"] = (input) => + Effect.try({ + try: () => { + if (input.provider !== undefined && input.provider !== PROVIDER) { + throw new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const existing = sessions.get(input.threadId); + const createdAt = existing?.createdAt ?? nowIso(); + const updatedAt = nowIso(); + const requestedBinaryPath = input.providerOptions?.cursor?.binaryPath; + const normalizedResumeCursor = normalizeResumeCursor(input.resumeCursor); + const next: CursorSessionState = { + createdAt, + binaryPath: + requestedBinaryPath !== undefined + ? resolveCursorBinaryPath(requestedBinaryPath) + : (existing?.binaryPath ?? resolveCursorBinaryPath(undefined)), + status: "ready", + runtimeMode: input.runtimeMode, + threadId: input.threadId, + ...(input.cwd ? { cwd: input.cwd } : existing?.cwd ? { cwd: existing.cwd } : {}), + ...(input.model + ? { model: input.model } + : existing?.model + ? { model: existing.model } + : {}), + ...(normalizedResumeCursor + ? { resumeCursor: normalizedResumeCursor } + : existing?.resumeCursor + ? { resumeCursor: existing.resumeCursor } + : {}), + updatedAt, + ...(existing?.lastError ? { lastError: existing.lastError } : {}), + }; + sessions.set(input.threadId, next); + emit({ + ...baseEvent(input.threadId), + type: "session.started", + payload: { + ...(next.resumeCursor + ? { resume: { sessionId: next.resumeCursor } } + : undefined), + }, + }); + emit({ + ...baseEvent(input.threadId), + type: "session.state.changed", + payload: { + state: "ready", + }, + }); + return toProviderSession(next); + }, + catch: (cause) => + Schema.is(ProviderAdapterValidationError)(cause) || + Schema.is(ProviderAdapterSessionNotFoundError)(cause) + ? cause + : new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start Cursor session."), + cause, + }), + }); + + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + Effect.try({ + try: (): ProviderTurnStartResult => { + if ((input.attachments?.length ?? 0) > 0) { + throw new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Cursor CLI image attachments are not supported yet.", + }); + } + + const session = requireSession(input.threadId); + if (session.activeTurn) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Thread '${input.threadId}' already has a running Cursor turn.`, + }); + } + + const prompt = buildCursorPrompt(input.input ?? "", input.interactionMode); + const model = input.model ?? session.model; + const turnId = TurnId.makeUnsafe(`cursor-turn:${crypto.randomUUID()}`); + const child = spawnProcess( + session.binaryPath, + buildCursorArgs({ + prompt, + runtimeMode: session.runtimeMode, + ...(model ? { model } : {}), + ...(session.resumeCursor ? { resumeCursor: session.resumeCursor } : {}), + }), + { + cwd: session.cwd, + env: process.env, + shell: process.platform === "win32", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + const stderrLines: string[] = []; + const activeTurn: ActiveTurnState = { + child, + turnId, + interactionMode: input.interactionMode, + interrupted: false, + assistantText: "", + assistantTextEmitted: false, + inFlightToolItemIds: new Set(), + }; + + session.status = "running"; + session.updatedAt = nowIso(); + session.lastError = undefined; + session.activeTurn = activeTurn; + if (model) { + session.model = model; + } + sessions.set(input.threadId, session); + + emit({ + ...baseEvent(input.threadId, turnId), + type: "turn.started", + payload: { + ...(model ? { model } : undefined), + }, + }); + + const handleJsonLine = (line: string) => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + + const record = asRecord(parsed); + if (!record) { + return; + } + + const sessionId = extractCursorSessionId(record); + if (sessionId) { + session.resumeCursor = sessionId; + session.updatedAt = nowIso(); + } + + const type = asTrimmedString(record.type); + const subtype = asTrimmedString(record.subtype); + + if (type === "assistant") { + const delta = extractText(record.message) ?? extractText(record.content) ?? extractText(record.text); + if (!delta) { + return; + } + activeTurn.assistantText += delta; + if (activeTurn.interactionMode !== "plan") { + activeTurn.assistantTextEmitted = true; + emit({ + ...baseEvent(input.threadId, turnId), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta, + }, + }); + } + return; + } + + if (type === "tool_call") { + const itemId = toolItemId(record, turnId); + const name = + asTrimmedString(asRecord(record.toolCall)?.name) ?? + asTrimmedString(asRecord(record.tool_call)?.name) ?? + asTrimmedString(record.name); + const itemType = toolItemType(name); + const detail = toolDetail(record); + if (subtype === "completed" || subtype === "failed" || subtype === "error") { + activeTurn.inFlightToolItemIds.delete(itemId); + emit({ + ...baseEvent(input.threadId, turnId), + itemId: RuntimeItemId.makeUnsafe(itemId), + type: "item.completed", + payload: { + itemType, + status: subtype === "completed" ? "completed" : "failed", + ...(name ? { title: name } : {}), + ...(detail ? { detail } : {}), + data: record, + }, + }); + return; + } + + activeTurn.inFlightToolItemIds.add(itemId); + emit({ + ...baseEvent(input.threadId, turnId), + itemId: RuntimeItemId.makeUnsafe(itemId), + type: "item.started", + payload: { + itemType, + status: "inProgress", + ...(name ? { title: name } : {}), + ...(detail ? { detail } : {}), + data: record, + }, + }); + return; + } + + if (type === "result") { + const finalText = + extractText(record.result) ?? extractText(record.message) ?? extractText(record.output); + if (finalText) { + activeTurn.finalText = finalText; + } + } + }; + + attachLineReader(child.stdout, handleJsonLine); + attachLineReader(child.stderr, (line) => { + stderrLines.push(line); + }); + + child.once("error", (cause) => { + const current = sessions.get(input.threadId); + if (!current || current.activeTurn?.turnId !== turnId) { + return; + } + current.activeTurn = undefined; + current.status = "error"; + current.updatedAt = nowIso(); + current.lastError = toMessage(cause, "Cursor CLI failed to start."); + sessions.set(input.threadId, current); + emit({ + ...baseEvent(input.threadId, turnId), + type: "runtime.error", + payload: { + message: current.lastError, + class: "transport_error", + }, + }); + emit({ + ...baseEvent(input.threadId, turnId), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: current.lastError, + }, + }); + }); + + child.once("close", (code, signal) => { + const current = sessions.get(input.threadId); + if (!current || current.activeTurn?.turnId !== turnId) { + return; + } + + for (const itemId of current.activeTurn.inFlightToolItemIds) { + emit({ + ...baseEvent(input.threadId, turnId), + itemId: RuntimeItemId.makeUnsafe(itemId), + type: "item.completed", + payload: { + itemType: "dynamic_tool_call", + status: current.activeTurn.interrupted ? "declined" : "failed", + }, + }); + } + + const completionText = + current.activeTurn.finalText ?? current.activeTurn.assistantText; + const failureMessage = stderrLines.join("\n").trim(); + current.activeTurn = undefined; + current.updatedAt = nowIso(); + + if (current.status === "error") { + sessions.set(input.threadId, current); + return; + } + + if (code === 0 && !current.lastError) { + current.status = "ready"; + current.lastError = undefined; + sessions.set(input.threadId, current); + if (completionText) { + if (input.interactionMode === "plan") { + emit({ + ...baseEvent(input.threadId, turnId), + type: "turn.proposed.completed", + payload: { + planMarkdown: completionText, + }, + }); + } else if (!activeTurn.assistantTextEmitted) { + emit({ + ...baseEvent(input.threadId, turnId), + itemId: RuntimeItemId.makeUnsafe(`cursor-assistant:${turnId}`), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + detail: completionText, + }, + }); + } + } + emit({ + ...baseEvent(input.threadId, turnId), + type: "turn.completed", + payload: { + state: activeTurn.interrupted ? "interrupted" : "completed", + ...(activeTurn.interrupted ? { stopReason: "interrupted" } : {}), + }, + }); + return; + } + + const message = + failureMessage.length > 0 + ? failureMessage + : `Cursor CLI exited with code ${code ?? "null"}${signal ? ` (${signal})` : ""}.`; + current.status = activeTurn.interrupted ? "ready" : "error"; + current.lastError = activeTurn.interrupted ? undefined : message; + sessions.set(input.threadId, current); + if (!activeTurn.interrupted) { + emit({ + ...baseEvent(input.threadId, turnId), + type: "runtime.error", + payload: { + message, + class: "provider_error", + }, + }); + } + emit({ + ...baseEvent(input.threadId, turnId), + type: "turn.completed", + payload: { + state: activeTurn.interrupted ? "interrupted" : "failed", + ...(activeTurn.interrupted + ? { stopReason: "interrupted" } + : { errorMessage: message }), + }, + }); + }); + + return { + threadId: input.threadId, + turnId, + ...(session.resumeCursor ? { resumeCursor: session.resumeCursor } : {}), + }; + }, + catch: (cause) => + Schema.is(ProviderAdapterValidationError)(cause) || + Schema.is(ProviderAdapterSessionNotFoundError)(cause) || + Schema.is(ProviderAdapterRequestError)(cause) + ? cause + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: toMessage(cause, "Failed to start Cursor turn."), + cause, + }), + }); + + const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => + Effect.try({ + try: () => { + const session = requireSession(threadId); + if (!session.activeTurn) { + return; + } + session.activeTurn.interrupted = true; + killChild(session.activeTurn.child); + }, + catch: (cause) => + Schema.is(ProviderAdapterSessionNotFoundError)(cause) + ? cause + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/interrupt", + detail: toMessage(cause, "Failed to interrupt Cursor turn."), + cause, + }), + }); + + const unsupported = (operation: string, issue: string) => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation, + issue, + }), + ); + + const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + _threadId, + _requestId, + _decision, + ) => + unsupported( + "respondToRequest", + "Cursor CLI does not expose interactive approval requests in this adapter.", + ); + + const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + _threadId, + _requestId, + _answers, + ) => + unsupported( + "respondToUserInput", + "Cursor CLI does not expose structured user-input requests in this adapter.", + ); + + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => + Effect.sync(() => { + const session = sessions.get(threadId); + if (!session) { + return; + } + if (session.activeTurn) { + session.activeTurn.interrupted = true; + killChild(session.activeTurn.child); + } + sessions.delete(threadId); + emit({ + ...baseEvent(threadId), + type: "session.exited", + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + }); + }); + + const listSessions: CursorAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (session) => toProviderSession(session))); + + const hasSession: CursorAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: CursorAdapterShape["readThread"] = (_threadId) => + unsupported("readThread", "Cursor CLI thread history reading is not implemented."); + + const rollbackThread: CursorAdapterShape["rollbackThread"] = (_threadId, _numTurns) => + unsupported("rollbackThread", "Cursor CLI thread rollback is not implemented."); + + const stopAll: CursorAdapterShape["stopAll"] = () => + Effect.sync(() => { + for (const [threadId, session] of sessions) { + if (session.activeTurn) { + session.activeTurn.interrupted = true; + killChild(session.activeTurn.child); + } + emit({ + ...baseEvent(threadId), + type: "session.exited", + payload: { + reason: "All sessions stopped", + exitKind: "graceful", + }, + }); + } + sessions.clear(); + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + 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 a50112a62d..f0a4aa8622 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -5,6 +5,7 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; 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"; @@ -27,11 +28,28 @@ const fakeCodexAdapter: CodexAdapterShape = { streamEvents: Stream.empty, }; +const fakeCursorAdapter: CursorAdapterShape = { + provider: "cursor", + capabilities: { sessionModelSwitch: "in-session" }, + 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( - ProviderAdapterRegistryLive, - Layer.succeed(CodexAdapter, fakeCodexAdapter), + ProviderAdapterRegistryLive.pipe( + Layer.provide(Layer.succeed(CodexAdapter, fakeCodexAdapter)), + Layer.provide(Layer.succeed(CursorAdapter, fakeCursorAdapter)), ), NodeServices.layer, ), @@ -42,10 +60,12 @@ layer("ProviderAdapterRegistryLive", (it) => { Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); + const cursor = yield* registry.getByProvider("cursor"); assert.equal(codex, fakeCodexAdapter); + assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex"]); + assert.deepEqual(providers, ["codex", "cursor"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 4f2c7f2c7e..ef7ab2f913 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -16,6 +16,7 @@ import { type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -26,7 +27,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter]; + : [yield* CodexAdapter, yield* CursorAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 90df9b691f..808ae41e32 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -4,7 +4,11 @@ import { Effect, Layer, Sink, Stream } from "effect"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth"; +import { + checkCodexProviderStatus, + checkCursorProviderStatus, + parseAuthStatusFromOutput, +} from "./ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── @@ -85,6 +89,50 @@ it.effect("returns unavailable when codex is missing", () => }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), ); +it.effect("returns warning when cursor is installed but auth cannot be verified", () => + Effect.gen(function* () { + const previousApiKey = process.env.CURSOR_API_KEY; + delete process.env.CURSOR_API_KEY; + try { + const status = yield* checkCursorProviderStatus; + assert.strictEqual(status.provider, "cursor"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + } finally { + if (previousApiKey === undefined) { + delete process.env.CURSOR_API_KEY; + } else { + process.env.CURSOR_API_KEY = previousApiKey; + } + } + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "cursor-agent 0.1.0\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), +); + +it.effect("returns unavailable when cursor is missing", () => + Effect.gen(function* () { + const status = yield* checkCursorProviderStatus; + assert.strictEqual(status.provider, "cursor"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Cursor CLI (`cursor-agent`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn cursor-agent ENOENT"))), +); + it.effect("returns unavailable when codex is below the minimum supported version", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus; diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 59f41edf81..7cda8079f1 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -21,6 +21,7 @@ import { isCodexCliVersionSupported, parseCodexCliVersion, } from "../codexCliVersion"; +import { CURSOR_CLI_BINARY, CURSOR_PROVIDER } from "../cursorCli.ts"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; const DEFAULT_TIMEOUT_MS = 4_000; @@ -40,12 +41,12 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function isCommandMissingCause(error: unknown): boolean { +function isCommandMissingCause(error: unknown, command: string): boolean { if (!(error instanceof Error)) return false; const lower = error.message.toLowerCase(); return ( - lower.includes("command not found: codex") || - lower.includes("spawn codex enoent") || + lower.includes(`command not found: ${command}`) || + lower.includes(`spawn ${command} enoent`) || lower.includes("enoent") || lower.includes("notfound") ); @@ -176,10 +177,10 @@ const collectStreamAsString = (stream: Stream.Stream): Effect. (acc, chunk) => acc + new TextDecoder().decode(chunk), ); -const runCodexCommand = (args: ReadonlyArray) => +const runCommand = (commandName: string, args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { + const command = ChildProcess.make(commandName, [...args], { shell: process.platform === "win32", }); @@ -207,7 +208,7 @@ export const checkCodexProviderStatus: Effect.Effect< const checkedAt = new Date().toISOString(); // Probe 1: `codex --version` — is the CLI reachable? - const versionProbe = yield* runCodexCommand(["--version"]).pipe( + const versionProbe = yield* runCommand("codex", ["--version"]).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -220,7 +221,7 @@ export const checkCodexProviderStatus: Effect.Effect< available: false, authStatus: "unknown" as const, checkedAt, - message: isCommandMissingCause(error) + message: isCommandMissingCause(error, "codex") ? "Codex CLI (`codex`) is not installed or not on PATH." : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, }; @@ -265,7 +266,7 @@ export const checkCodexProviderStatus: Effect.Effect< } // Probe 2: `codex login status` — is the user authenticated? - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( + const authProbe = yield* runCommand("codex", ["login", "status"]).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); @@ -307,14 +308,82 @@ export const checkCodexProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkCursorProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + const versionProbe = yield* runCommand(CURSOR_CLI_BINARY, ["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CURSOR_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause(error, CURSOR_CLI_BINARY) + ? "Cursor CLI (`cursor-agent`) is not installed or not on PATH." + : `Failed to execute Cursor CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CURSOR_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Cursor CLI is installed but failed to run. Timed out while running command.", + }; + } + + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CURSOR_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Cursor CLI is installed but failed to run. ${detail}` + : "Cursor CLI is installed but failed to run.", + }; + } + + const hasApiKey = nonEmptyTrimmed(process.env.CURSOR_API_KEY) !== undefined; + return { + provider: CURSOR_PROVIDER, + status: hasApiKey ? "ready" : "warning", + available: true, + authStatus: hasApiKey ? "authenticated" : "unknown", + checkedAt, + ...(!hasApiKey + ? { + message: + "Cursor CLI is installed. Authentication could not be verified automatically; ensure `cursor-agent login` has been run or set CURSOR_API_KEY.", + } + : {}), + } satisfies ServerProviderStatus; +}); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { const codexStatus = yield* checkCodexProviderStatus; + const cursorStatus = yield* checkCursorProviderStatus; return { - getStatuses: Effect.succeed([codexStatus]), + getStatuses: Effect.succeed([codexStatus, cursorStatus]), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 69e1e439bf..91a2b46f53 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,5 +1,5 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; -import { Effect, Layer, Option } from "effect"; +import { ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { Effect, Layer, Option, Schema } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { @@ -25,14 +25,14 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { - return Effect.succeed(providerName); - } - return Effect.fail( - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Unknown persisted provider '${providerName}'.`, - }), + return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe( + Effect.mapError( + () => + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Unknown persisted provider '${providerName}'.`, + }), + ), ); } diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts new file mode 100644 index 0000000000..8b64238955 --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CursorAdapterShape extends ProviderAdapterShape { + readonly provider: "cursor"; +} + +export class CursorAdapter extends ServiceMap.Service()( + "t3/provider/Services/CursorAdapter", +) {} diff --git a/apps/server/src/provider/cursorCli.ts b/apps/server/src/provider/cursorCli.ts new file mode 100644 index 0000000000..defd6bee0b --- /dev/null +++ b/apps/server/src/provider/cursorCli.ts @@ -0,0 +1,7 @@ +export const CURSOR_PROVIDER = "cursor" as const; +export const CURSOR_CLI_BINARY = "cursor-agent"; + +export function resolveCursorBinaryPath(binaryPath: string | null | undefined): string { + const trimmed = binaryPath?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : CURSOR_CLI_BINARY; +} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b9..4a9d58cb30 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { ProviderUnsupportedError } from "./provider/Errors"; 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"; @@ -57,8 +58,10 @@ export function makeServerProviderLayer(): Layer.Layer< const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const cursorAdapterLayer = makeCursorAdapterLive(); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), + Layer.provide(cursorAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e58f7af4a6..7b45d07a19 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -28,6 +28,7 @@ const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -37,6 +38,9 @@ const AppSettingsSchema = Schema.Struct({ codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + cursorBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), @@ -45,6 +49,9 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customCursorModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -108,6 +115,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f4199..2d5f214538 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -803,7 +803,8 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = settings.customCodexModels; + const customModelsForSelectedProvider = + selectedProvider === "cursor" ? settings.customCursorModels : settings.customCodexModels; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { @@ -830,6 +831,29 @@ export default function ChatView({ threadId }: ChatViewProps) { }; return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); + const selectedProviderOptionsForDispatch = useMemo(() => { + if (selectedProvider === "codex") { + const codexOptions = { + ...(settings.codexBinaryPath.trim().length > 0 + ? { binaryPath: settings.codexBinaryPath.trim() } + : {}), + ...(settings.codexHomePath.trim().length > 0 + ? { homePath: settings.codexHomePath.trim() } + : {}), + }; + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; + } + + const trimmedCursorBinaryPath = settings.cursorBinaryPath.trim(); + const cursorOptions = + trimmedCursorBinaryPath.length > 0 ? { binaryPath: trimmedCursorBinaryPath } : {}; + return Object.keys(cursorOptions).length > 0 ? { cursor: cursorOptions } : undefined; + }, [ + selectedProvider, + settings.codexBinaryPath, + settings.codexHomePath, + settings.cursorBinaryPath, + ]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), @@ -2626,6 +2650,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), provider: selectedProvider, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2903,6 +2930,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, @@ -2933,6 +2963,7 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, + selectedProviderOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, setThreadError, @@ -3003,6 +3034,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), + ...(selectedProviderOptionsForDispatch + ? { providerOptions: selectedProviderOptionsForDispatch } + : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", @@ -3052,6 +3086,7 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedModel, selectedModelOptionsForDispatch, + selectedProviderOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, syncServerReadModel, @@ -3067,7 +3102,11 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, - resolveAppModelSelection(provider, settings.customCodexModels, model), + resolveAppModelSelection( + provider, + provider === "cursor" ? settings.customCursorModels : settings.customCodexModels, + model, + ), ); scheduleComposerFocus(); }, @@ -3077,6 +3116,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + settings.customCursorModels, settings.customCodexModels, ], ); @@ -5297,9 +5337,11 @@ const COMING_SOON_PROVIDER_OPTIONS = [ function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customCursorModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + cursor: getAppModelOptions("cursor", settings.customCursorModels), }; } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2ac03a3ed3..23c8a70f32 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -208,7 +208,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return value === "codex" || value === "cursor" ? value : null; } function revokeObjectPreviewUrl(previewUrl: string): void { @@ -809,9 +809,10 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const normalizedModel = normalizeModelSlug(model) ?? null; set((state) => { const existing = state.draftsByThreadId[threadId]; + const provider = existing?.provider ?? null; + const normalizedModel = normalizeModelSlug(model, provider ?? "codex") ?? null; if (!existing && normalizedModel === null) { return state; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a272..371f18c512 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -54,6 +54,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "cursor", + title: "Cursor", + description: "Save additional Cursor model slugs for the picker and `/model` command.", + placeholder: "your-cursor-model-slug", + example: "claude-sonnet-4", + }, ] as const; function getCustomModelsForProvider( @@ -62,8 +69,9 @@ function getCustomModelsForProvider( ) { switch (provider) { case "codex": - default: return settings.customCodexModels; + case "cursor": + return settings.customCursorModels; } } @@ -73,16 +81,18 @@ function getDefaultCustomModelsForProvider( ) { switch (provider) { case "codex": - default: return defaults.customCodexModels; + case "cursor": + return defaults.customCursorModels; } } function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { case "codex": - default: return { customCodexModels: models }; + case "cursor": + return { customCursorModels: models }; } } @@ -96,6 +106,7 @@ function SettingsRouteView() { Record >({ codex: "", + cursor: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -103,6 +114,7 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const cursorBinaryPath = settings.cursorBinaryPath; const codexServiceTier = settings.codexServiceTier; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; @@ -300,6 +312,51 @@ function SettingsRouteView() { +
+
+

Cursor CLI

+

+ Optional overrides for the Cursor agent binary used by new Cursor-backed turns. +

+
+ +
+ + +
+

+ Binary source:{" "} + + {cursorBinaryPath || "PATH"} + +

+ +
+
+
+

Models

diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e9351ca2b2..59d6b8f706 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -19,7 +19,7 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeCode", label: "Claude Code", available: false }, - { value: "cursor", label: "Cursor", available: false }, + { value: "cursor", label: "Cursor", available: true }, ]; export interface WorkLogEntry { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 65c9665378..5bf37e7f01 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -143,21 +143,26 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex") { + if (providerName === "codex" || providerName === "cursor") { return providerName; } return "codex"; } const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug)); function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex") { + if (input.sessionProviderName === "codex" || input.sessionProviderName === "cursor") { return input.sessionProviderName; } + const normalizedCursor = normalizeModelSlug(input.model, "cursor"); + if (normalizedCursor && CURSOR_MODEL_SLUGS.has(normalizedCursor)) { + return "cursor"; + } const normalizedCodex = normalizeModelSlug(input.model, "codex"); if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { return "codex"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09dc..a273999cdb 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -28,6 +28,7 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + cursor: [{ slug: "gpt-5", name: "GPT-5" }], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +37,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + cursor: "gpt-5", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +48,17 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + cursor: { + "5": "gpt-5", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, + cursor: [], } as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", + cursor: null, } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 25a641edbb..99f7607a72 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -186,6 +186,32 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => }), ); +it.effect("accepts provider-scoped start options in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-cursor", + threadId: "thread-1", + message: { + messageId: "msg-cursor", + role: "user", + text: "hello", + attachments: [], + }, + provider: "cursor", + model: "gpt-5", + providerOptions: { + cursor: { + binaryPath: "/usr/local/bin/cursor-agent", + }, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, "cursor"); + assert.strictEqual(parsed.providerOptions?.cursor?.binaryPath, "/usr/local/bin/cursor-agent"); + }), +); + it.effect( "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", () => diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index aa7bd827de..e586761730 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literal("codex"); +export const ProviderKind = Schema.Literals(["codex", "cursor"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -366,6 +366,21 @@ export const ThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional( + Schema.Struct({ + codex: Schema.optional( + Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), + }), + ), + cursor: Schema.optional( + Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + }), + ), + }), + ), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -388,6 +403,21 @@ const ClientThreadTurnStartCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional( + Schema.Struct({ + codex: Schema.optional( + Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), + }), + ), + cursor: Schema.optional( + Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + }), + ), + }), + ), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -668,6 +698,21 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), modelOptions: Schema.optional(ProviderModelOptions), + providerOptions: Schema.optional( + Schema.Struct({ + codex: Schema.optional( + Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + homePath: Schema.optional(TrimmedNonEmptyString), + }), + ), + cursor: Schema.optional( + Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + }), + ), + }), + ), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 997db09b78..f11709f9b4 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -34,6 +34,24 @@ describe("ProviderSessionStartInput", () => { expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); + it("accepts cursor-compatible payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "cursor", + cwd: "/tmp/workspace", + model: "gpt-5", + runtimeMode: "full-access", + providerOptions: { + cursor: { + binaryPath: "/usr/local/bin/cursor-agent", + }, + }, + }); + + expect(parsed.provider).toBe("cursor"); + expect(parsed.providerOptions?.cursor?.binaryPath).toBe("/usr/local/bin/cursor-agent"); + }); + it("rejects payloads without runtime mode", () => { expect(() => decodeProviderSessionStartInput({ diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9ca7068ad3..edc2b34919 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -53,8 +53,13 @@ const CodexProviderStartOptions = Schema.Struct({ homePath: Schema.optional(TrimmedNonEmptyStringSchema), }); +const CursorProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), +}); + const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), + cursor: Schema.optional(CursorProviderStartOptions), }); export const ProviderSessionStartInput = Schema.Struct({ diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8771a24c1c..0348823107 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -26,6 +26,7 @@ describe("normalizeModelSlug", () => { it("preserves non-aliased model slugs", () => { expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2"); expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); + expect(normalizeModelSlug("5", "cursor")).toBe("gpt-5"); }); it("does not leak prototype properties as aliases", () => { @@ -60,10 +61,15 @@ describe("getReasoningEffortOptions", () => { it("returns codex reasoning options for codex", () => { expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]); }); + + it("returns no reasoning options for cursor", () => { + expect(getReasoningEffortOptions("cursor")).toEqual([]); + }); }); describe("getDefaultReasoningEffort", () => { it("returns provider-scoped defaults", () => { expect(getDefaultReasoningEffort("codex")).toBe("high"); + expect(getDefaultReasoningEffort("cursor")).toBeNull(); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9f..357bddbf86 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,6 +12,7 @@ type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") {