Skip to content

Commit b4fea02

Browse files
committed
Added Cursor ACP provider with full server-side and web UI integration
- Implemented complete Cursor ACP (Agent Client Protocol) provider using the Cursor Agent CLI - Created new effect-acp package for ACP protocol handling (JSON-RPC over stdio) - Added server-side components: CursorProvider, CursorAdapter, AcpSessionRuntime - Added ACP support modules: AcpSessionRuntime, AcpRuntimeModel, AcpCoreRuntimeEvents, CursorAcpSupport, CursorAcpExtension, AcpNativeLogging - Added --trust flag to agent spawn to bypass workspace trust prompts in headless mode - Fixed probe cwd to use os.homedir() instead of process.cwd() to avoid trust issues - Added Cursor to ProviderRegistry, ProviderAdapterRegistry, and RoutingTextGeneration - Added Cursor settings contracts: CursorSettings, CursorModelOptions, CursorModelSelection - Updated "cursor" as a new ProviderKind variant - Added web UI support: Provider settings card, composer registry, model selection helpers - Fixed "Restore Defaults" button to only appear on General settings tab using useLocation - Improved force-delete project modal to show thread count and use "Delete" terminology - Simplified ProviderPickerKind to just ProviderKind (removed redundant "cursor" union) - Updated all tests to include cursor provider in expected provider lists - Added temporary debug logging for ACP protocol parsing issues Files changed: - 30+ modified files across apps/server, apps/web, packages/contracts, packages/shared - 10+ new files including entire effect-acp package and Cursor provider modules
1 parent 1f97f2b commit b4fea02

56 files changed

Lines changed: 17103 additions & 36 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@opencode-ai/sdk": "^1.3.13",
3434
"@pierre/diffs": "^1.1.0-beta.16",
3535
"effect": "catalog:",
36+
"effect-acp": "workspace:*",
3637
"node-pty": "^1.1.0",
3738
"open": "^10.1.0"
3839
},

apps/server/src/git/Layers/RoutingTextGeneration.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function normalizeTextGenerationModelSelection(
5656
model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.claudeAgent,
5757
};
5858
case "copilot":
59+
case "cursor":
5960
default:
6061
return {
6162
provider: "codex",
@@ -136,6 +137,14 @@ const makeRoutingTextGeneration = Effect.gen(function* () {
136137
modelSelection: input.modelSelection,
137138
},
138139
);
140+
case "cursor":
141+
return route("codex").generateThreadTitle({
142+
...input,
143+
modelSelection: {
144+
provider: "codex",
145+
model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
146+
},
147+
});
139148
case "opencode":
140149
return generateOpencodeThreadTitleNative(
141150
{

apps/server/src/provider/Layers/ClaudeProvider.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,27 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
8383
promptInjectedEffortLevels: ["ultrathink"],
8484
} satisfies ModelCapabilities,
8585
},
86+
{
87+
slug: "claude-opus-4-5",
88+
name: "Claude Opus 4.5",
89+
isCustom: false,
90+
capabilities: {
91+
reasoningEffortLevels: [
92+
{ value: "low", label: "Low" },
93+
{ value: "medium", label: "Medium" },
94+
{ value: "high", label: "High", isDefault: true },
95+
{ value: "max", label: "Max" },
96+
{ value: "ultrathink", label: "Ultrathink" },
97+
],
98+
supportsFastMode: true,
99+
supportsThinkingToggle: false,
100+
contextWindowOptions: [
101+
{ value: "200k", label: "200k", isDefault: true },
102+
{ value: "1m", label: "1M" },
103+
],
104+
promptInjectedEffortLevels: ["ultrathink"],
105+
} satisfies ModelCapabilities,
106+
},
86107
{
87108
slug: "claude-haiku-4-5",
88109
name: "Claude Haiku 4.5",
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
/**
2+
* CursorAdapter types and internal helpers.
3+
*
4+
* @module CursorAdapter.types
5+
*/
6+
import * as nodePath from "node:path";
7+
8+
import {
9+
ApprovalRequestId,
10+
type CursorModelOptions,
11+
EventId,
12+
type ProviderApprovalDecision,
13+
type ProviderInteractionMode,
14+
type ProviderSession,
15+
type ProviderUserInputAnswers,
16+
RuntimeRequestId,
17+
type RuntimeMode,
18+
type ThreadId,
19+
TurnId,
20+
} from "@bigcode/contracts";
21+
import {
22+
DateTime,
23+
Deferred,
24+
Effect,
25+
Exit,
26+
Fiber,
27+
FileSystem,
28+
Layer,
29+
Option,
30+
PubSub,
31+
Random,
32+
Scope,
33+
Semaphore,
34+
Stream,
35+
SynchronizedRef,
36+
} from "effect";
37+
import { ChildProcessSpawner } from "effect/unstable/process";
38+
import type * as EffectAcpSchema from "effect-acp/schema";
39+
40+
import { resolveAttachmentPath } from "../../attachments/attachmentStore.ts";
41+
import { ServerConfig } from "../../startup/config.ts";
42+
import { ServerSettingsService } from "../../ws/serverSettings.ts";
43+
import {
44+
ProviderAdapterProcessError,
45+
ProviderAdapterRequestError,
46+
ProviderAdapterSessionNotFoundError,
47+
ProviderAdapterValidationError,
48+
} from "../Errors.ts";
49+
import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
50+
import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts";
51+
import {
52+
makeAcpAssistantItemEvent,
53+
makeAcpContentDeltaEvent,
54+
makeAcpPlanUpdatedEvent,
55+
makeAcpRequestOpenedEvent,
56+
makeAcpRequestResolvedEvent,
57+
makeAcpToolCallEvent,
58+
} from "../acp/AcpCoreRuntimeEvents.ts";
59+
import {
60+
type AcpSessionMode,
61+
type AcpSessionModeState,
62+
parsePermissionRequest,
63+
} from "../acp/AcpRuntimeModel.ts";
64+
import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts";
65+
import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts";
66+
import {
67+
CursorAskQuestionRequest,
68+
CursorCreatePlanRequest,
69+
CursorUpdateTodosRequest,
70+
extractAskQuestions,
71+
extractPlanMarkdown,
72+
extractTodosAsPlan,
73+
} from "../acp/CursorAcpExtension.ts";
74+
import { CursorAdapter } from "../Services/CursorAdapter.ts";
75+
import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
76+
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
77+
78+
export const PROVIDER = "cursor" as const;
79+
export const CURSOR_RESUME_VERSION = 1 as const;
80+
export const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];
81+
export const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];
82+
export const ACP_APPROVAL_MODE_ALIASES = ["ask"];
83+
84+
export interface CursorAdapterLiveOptions {
85+
readonly nativeEventLogPath?: string;
86+
readonly nativeEventLogger?: EventNdjsonLogger;
87+
}
88+
89+
export interface PendingApproval {
90+
readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
91+
readonly kind: string | "unknown";
92+
}
93+
94+
export interface PendingUserInput {
95+
readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
96+
}
97+
98+
export interface CursorSessionContext {
99+
readonly threadId: ThreadId;
100+
session: ProviderSession;
101+
readonly scope: Scope.Closeable;
102+
readonly acp: AcpSessionRuntimeShape;
103+
notificationFiber: Fiber.Fiber<void, never> | undefined;
104+
readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
105+
readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
106+
readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
107+
lastPlanFingerprint: string | undefined;
108+
activeTurnId: TurnId | undefined;
109+
stopped: boolean;
110+
}
111+
112+
export function settlePendingApprovalsAsCancelled(
113+
pendingApprovals: ReadonlyMap<ApprovalRequestId, PendingApproval>,
114+
): Effect.Effect<void> {
115+
const pendingEntries = Array.from(pendingApprovals.values());
116+
return Effect.forEach(
117+
pendingEntries,
118+
(pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore),
119+
{ discard: true },
120+
);
121+
}
122+
123+
export function settlePendingUserInputsAsEmptyAnswers(
124+
pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>,
125+
): Effect.Effect<void> {
126+
const pendingEntries = Array.from(pendingUserInputs.values());
127+
return Effect.forEach(
128+
pendingEntries,
129+
(pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore),
130+
{ discard: true },
131+
);
132+
}
133+
134+
export function isRecord(value: unknown): value is Record<string, unknown> {
135+
return typeof value === "object" && value !== null && !Array.isArray(value);
136+
}
137+
138+
export function parseCursorResume(raw: unknown): { sessionId: string } | undefined {
139+
if (!isRecord(raw)) return undefined;
140+
if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined;
141+
if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined;
142+
return { sessionId: raw.sessionId.trim() };
143+
}
144+
145+
export function normalizeModeSearchText(mode: AcpSessionMode): string {
146+
return [mode.id, mode.name, mode.description]
147+
.filter((value): value is string => typeof value === "string" && value.length > 0)
148+
.join(" ")
149+
.toLowerCase()
150+
.replace(/[^a-z0-9]+/g, " ")
151+
.trim();
152+
}
153+
154+
export function findModeByAliases(
155+
modes: ReadonlyArray<AcpSessionMode>,
156+
aliases: ReadonlyArray<string>,
157+
): AcpSessionMode | undefined {
158+
const normalizedAliases = aliases.map((alias) => alias.toLowerCase());
159+
for (const alias of normalizedAliases) {
160+
const exact = modes.find((mode) => {
161+
const id = mode.id.toLowerCase();
162+
const name = mode.name.toLowerCase();
163+
return id === alias || name === alias;
164+
});
165+
if (exact) {
166+
return exact;
167+
}
168+
}
169+
for (const alias of normalizedAliases) {
170+
const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias));
171+
if (partial) {
172+
return partial;
173+
}
174+
}
175+
return undefined;
176+
}
177+
178+
export function isPlanMode(mode: AcpSessionMode): boolean {
179+
return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined;
180+
}
181+
182+
export function resolveRequestedModeId(input: {
183+
readonly interactionMode: ProviderInteractionMode | undefined;
184+
readonly runtimeMode: RuntimeMode;
185+
readonly modeState: AcpSessionModeState | undefined;
186+
}): string | undefined {
187+
const modeState = input.modeState;
188+
if (!modeState) {
189+
return undefined;
190+
}
191+
192+
if (input.interactionMode === "plan") {
193+
return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id;
194+
}
195+
196+
if (input.runtimeMode === "approval-required") {
197+
return (
198+
findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
199+
findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
200+
modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
201+
modeState.currentModeId
202+
);
203+
}
204+
205+
return (
206+
findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
207+
findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
208+
modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
209+
modeState.currentModeId
210+
);
211+
}
212+
213+
export function applyRequestedSessionConfiguration<E>(input: {
214+
readonly runtime: AcpSessionRuntimeShape;
215+
readonly runtimeMode: RuntimeMode;
216+
readonly interactionMode: ProviderInteractionMode | undefined;
217+
readonly modelSelection:
218+
| {
219+
readonly model: string;
220+
readonly options?: CursorModelOptions | null | undefined;
221+
}
222+
| undefined;
223+
readonly mapError: (context: {
224+
readonly cause: import("effect-acp/errors").AcpError;
225+
readonly method: "session/set_config_option" | "session/set_mode";
226+
}) => E;
227+
}): Effect.Effect<void, E> {
228+
return Effect.gen(function* () {
229+
if (input.modelSelection) {
230+
yield* applyCursorAcpModelSelection({
231+
runtime: input.runtime,
232+
model: input.modelSelection.model,
233+
modelOptions: input.modelSelection.options,
234+
mapError: ({ cause }) =>
235+
input.mapError({
236+
cause,
237+
method: "session/set_config_option",
238+
}),
239+
});
240+
}
241+
242+
const requestedModeId = resolveRequestedModeId({
243+
interactionMode: input.interactionMode,
244+
runtimeMode: input.runtimeMode,
245+
modeState: yield* input.runtime.getModeState,
246+
});
247+
if (!requestedModeId) {
248+
return;
249+
}
250+
251+
yield* input.runtime.setMode(requestedModeId).pipe(
252+
Effect.mapError((cause) =>
253+
input.mapError({
254+
cause,
255+
method: "session/set_mode",
256+
}),
257+
),
258+
);
259+
});
260+
}
261+
262+
export function selectAutoApprovedPermissionOption(
263+
request: EffectAcpSchema.RequestPermissionRequest,
264+
): string | undefined {
265+
const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always");
266+
if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) {
267+
return allowAlwaysOption.optionId.trim();
268+
}
269+
270+
const allowOnceOption = request.options.find((option) => option.kind === "allow_once");
271+
if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) {
272+
return allowOnceOption.optionId.trim();
273+
}
274+
275+
return undefined;
276+
}
277+
278+
// Re-export everything needed by CursorAdapter.ts
279+
export {
280+
nodePath,
281+
ApprovalRequestId,
282+
EventId,
283+
RuntimeRequestId,
284+
TurnId,
285+
DateTime,
286+
Deferred,
287+
Effect,
288+
Exit,
289+
Fiber,
290+
FileSystem,
291+
Layer,
292+
Option,
293+
PubSub,
294+
Random,
295+
Scope,
296+
Semaphore,
297+
Stream,
298+
SynchronizedRef,
299+
ChildProcessSpawner,
300+
resolveAttachmentPath,
301+
ServerConfig,
302+
ServerSettingsService,
303+
ProviderAdapterProcessError,
304+
ProviderAdapterRequestError,
305+
ProviderAdapterSessionNotFoundError,
306+
ProviderAdapterValidationError,
307+
acpPermissionOutcome,
308+
mapAcpToAdapterError,
309+
makeAcpAssistantItemEvent,
310+
makeAcpContentDeltaEvent,
311+
makeAcpPlanUpdatedEvent,
312+
makeAcpRequestOpenedEvent,
313+
makeAcpRequestResolvedEvent,
314+
makeAcpToolCallEvent,
315+
parsePermissionRequest,
316+
makeAcpNativeLoggers,
317+
applyCursorAcpModelSelection,
318+
makeCursorAcpRuntime,
319+
CursorAskQuestionRequest,
320+
CursorCreatePlanRequest,
321+
CursorUpdateTodosRequest,
322+
extractAskQuestions,
323+
extractPlanMarkdown,
324+
extractTodosAsPlan,
325+
CursorAdapter,
326+
resolveCursorAcpBaseModelId,
327+
makeEventNdjsonLogger,
328+
};

0 commit comments

Comments
 (0)