Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
508 changes: 508 additions & 0 deletions docs/proposals/cursor-agent-support.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/app/main/ipc/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import type { AgentProviderId } from '../../../shared/contracts/dto'
import { createAppError } from '../../../shared/errors/appError'

export function normalizeProvider(value: unknown): AgentProviderId {
if (value !== 'claude-code' && value !== 'codex' && value !== 'opencode' && value !== 'gemini') {
if (
value !== 'claude-code' &&
value !== 'codex' &&
value !== 'opencode' &&
value !== 'gemini' &&
value !== 'cursor-agent'
) {
throw createAppError('common.invalid_input', { debugMessage: 'Invalid provider' })
}

Expand Down
38 changes: 33 additions & 5 deletions src/contexts/agent/infrastructure/cli/AgentCliAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { resolveAgentCliCommand } from './AgentCommandFactory'

const execFileAsync = promisify(execFile)

const AGENT_PROVIDERS: readonly AgentProviderId[] = ['claude-code', 'codex', 'opencode', 'gemini']
const AGENT_PROVIDERS: readonly AgentProviderId[] = [
'claude-code',
'codex',
'opencode',
'gemini',
'cursor-agent',
]

async function isCommandAvailable(command: string): Promise<boolean> {
const probeCommand = process.platform === 'win32' ? 'where.exe' : 'which'
Expand All @@ -18,12 +24,34 @@ async function isCommandAvailable(command: string): Promise<boolean> {
}
}

async function isCursorAgent(command: string): Promise<boolean> {
try {
const { stdout, stderr } = await execFileAsync(command, ['--help'], {
windowsHide: true,
timeout: 3000,
})
const output = `${stdout}${stderr}`.toLowerCase()
return output.includes('cursor')
} catch {
return false
}
}

export async function listInstalledAgentProviders(): Promise<AgentProviderId[]> {
const availability = await Promise.all(
AGENT_PROVIDERS.map(async provider => ({
provider,
available: await isCommandAvailable(resolveAgentCliCommand(provider)),
})),
AGENT_PROVIDERS.map(async provider => {
const command = resolveAgentCliCommand(provider)
const commandExists = await isCommandAvailable(command)
if (!commandExists) {
return { provider, available: false }
}

if (provider === 'cursor-agent') {
return { provider, available: await isCursorAgent(command) }
}

return { provider, available: true }
}),
)

return availability.filter(result => result.available).map(result => result.provider)
Expand Down
46 changes: 46 additions & 0 deletions src/contexts/agent/infrastructure/cli/AgentCommandFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export function resolveAgentCliCommand(provider: AgentProviderId): string {
return 'gemini'
}

if (provider === 'cursor-agent') {
return 'agent'
}

return 'codex'
}

Expand Down Expand Up @@ -194,6 +198,48 @@ export function buildAgentLaunchCommand(input: BuildAgentLaunchCommandInput): Ag
}
}

if (input.provider === 'cursor-agent') {
const args: string[] = []

if (agentFullAccess) {
args.push('--yolo')
}

if (effectiveModel) {
args.push('--model', effectiveModel)
}

if (input.mode === 'resume') {
if (resumeSessionId) {
args.push('--resume', resumeSessionId)
} else {
args.push('--continue')
}

return {
command: 'agent',
args,
launchMode: 'resume',
effectiveModel,
resumeSessionId,
}
}

const prompt = normalizePrompt(input.prompt)
if (prompt.length > 0) {
maybeTerminateOptionParsing(args, prompt)
args.push(prompt)
}

return {
command: 'agent',
args,
launchMode: 'new',
effectiveModel,
resumeSessionId: null,
}
}

if (input.mode === 'resume') {
if (!resumeSessionId) {
throw new Error('codex resume requires explicit session id')
Expand Down
121 changes: 121 additions & 0 deletions src/contexts/agent/infrastructure/cli/AgentModelService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const CODEX_MODEL_CACHE_TTL_MS = 30_000
const CODEX_MODEL_ERROR_CACHE_TTL_MS = 5_000
const GEMINI_MODEL_CACHE_TTL_MS = 6 * 60 * 60 * 1000
const GEMINI_MODEL_FALLBACK_CACHE_TTL_MS = 5 * 60 * 1000
const CURSOR_AGENT_MODEL_CACHE_TTL_MS = 30_000
const CURSOR_AGENT_MODEL_ERROR_CACHE_TTL_MS = 5_000
const CLI_MODEL_LIST_TIMEOUT_MS = 8000
const CLI_MODEL_LIST_MAX_BUFFER_BYTES = 16 * 1024 * 1024

Expand All @@ -31,6 +33,13 @@ let cachedGeminiModels: {

let geminiModelsRequestInFlight: Promise<ListAgentModelsResult> | null = null

let cachedCursorAgentModels: {
result: ListAgentModelsResult
expiresAtMs: number
} | null = null

let cursorAgentModelsRequestInFlight: Promise<ListAgentModelsResult> | null = null

const CLAUDE_CODE_STATIC_MODELS: AgentModelOption[] = [
{
id: 'claude-sonnet-4-6',
Expand Down Expand Up @@ -141,6 +150,32 @@ function readCachedGeminiModels(): ListAgentModelsResult | null {
return cloneListAgentModelsResult(cachedGeminiModels.result)
}

function rememberCursorAgentModels(result: ListAgentModelsResult): ListAgentModelsResult {
cachedCursorAgentModels = {
result: cloneListAgentModelsResult(result),
expiresAtMs:
Date.now() +
(result.error === null
? CURSOR_AGENT_MODEL_CACHE_TTL_MS
: CURSOR_AGENT_MODEL_ERROR_CACHE_TTL_MS),
}

return cloneListAgentModelsResult(result)
}

function readCachedCursorAgentModels(): ListAgentModelsResult | null {
if (!cachedCursorAgentModels) {
return null
}

if (Date.now() > cachedCursorAgentModels.expiresAtMs) {
cachedCursorAgentModels = null
return null
}

return cloneListAgentModelsResult(cachedCursorAgentModels.result)
}

async function executeCliText(command: string, args: string[]): Promise<string> {
const invocation = await resolveAgentCliInvocation({ command, args })

Expand Down Expand Up @@ -185,15 +220,63 @@ async function listOpenCodeModelsFromCli(): Promise<AgentModelOption[]> {
}))
}

function stripAnsiCodes(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/\u001B\[[0-9;]*[A-Za-z]|\u001B\].*?\u0007/g, '')
}

async function listCursorAgentModelsFromCli(): Promise<AgentModelOption[]> {
const stdout = await executeCliText('agent', ['models'])
const cleaned = stripAnsiCodes(stdout)

const models: AgentModelOption[] = []
for (const rawLine of cleaned.split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(/^([a-z0-9][a-z0-9._-]*)\s+-\s+(.+)$/)
if (!match) {
continue
}

const id = match[1]
let displayName = match[2].trim()
const isCurrent = /\(current\)\s*$/.test(displayName)
const isDefault = /\(default\)\s*$/.test(displayName)
displayName = displayName.replace(/\s*\((current|default)\)\s*/g, '').trim()

models.push({
id,
displayName,
description: isCurrent ? 'Current model' : isDefault ? 'Default model' : '',
isDefault: isCurrent || isDefault,
})
}

return models
}

function listClaudeCodeStaticModels(): AgentModelOption[] {
return CLAUDE_CODE_STATIC_MODELS.map(model => ({ ...model }))
}

export async function resolveDefaultModelDisplayName(
provider: AgentProviderId,
): Promise<string | null> {
try {
const result = await listAgentModels(provider)
const defaultModel = result.models.find(m => m.isDefault)
return defaultModel?.displayName ?? null
} catch {
return null
}
}

export function disposeAgentModelService(): void {
codexModelsRequestInFlight = null
cachedCodexModels = null
geminiModelsRequestInFlight = null
cachedGeminiModels = null
cursorAgentModelsRequestInFlight = null
cachedCursorAgentModels = null

disposeCodexModelCatalog()
}
Expand Down Expand Up @@ -261,6 +344,44 @@ export async function listAgentModels(provider: AgentProviderId): Promise<ListAg
}
}

if (provider === 'cursor-agent') {
const cachedResult = readCachedCursorAgentModels()
if (cachedResult) {
return cachedResult
}

if (!cursorAgentModelsRequestInFlight) {
cursorAgentModelsRequestInFlight = (async () => {
const fetchedAt = new Date().toISOString()

try {
const models = await listCursorAgentModelsFromCli()
return rememberCursorAgentModels({
provider,
source: 'cursor-agent-cli',
fetchedAt,
models,
error: null,
})
} catch (error) {
return rememberCursorAgentModels({
provider,
source: 'cursor-agent-cli',
fetchedAt,
models: [],
error: createAppErrorDescriptor('agent.list_models_failed', {
debugMessage: toErrorMessage(error),
}),
})
} finally {
cursorAgentModelsRequestInFlight = null
}
})()
}

return cloneListAgentModelsResult(await cursorAgentModelsRequestInFlight)
}

if (provider === 'gemini') {
const cachedResult = readCachedGeminiModels()
if (cachedResult) {
Expand Down
4 changes: 4 additions & 0 deletions src/contexts/agent/infrastructure/cli/AgentSessionLocator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,10 @@ async function tryFindResumeSessionId(
return await findOpenCodeResumeSessionId(cwd, startedAtMs)
}

if (provider === 'cursor-agent') {
return null
}

return await findGeminiResumeSessionId(cwd, startedAtMs)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ async function tryResolveSessionFilePath(
return await findGeminiSessionFilePath(cwd, sessionId)
}

if (provider === 'cursor-agent') {
return null
}

return null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,5 +232,9 @@ export function extractLastAssistantMessageFromSessionData(
return extractOpenCodeAssistantMessage(parsed)
}

if (provider === 'cursor-agent') {
return null
}

return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ function mayContainTurnState(provider: AgentProviderId, line: string): boolean {
return false
}

if (provider === 'cursor-agent') {
return false
}

if (provider === 'claude-code') {
return line.includes('"assistant"') || line.includes('"user"')
}
Expand Down Expand Up @@ -179,6 +183,10 @@ export function detectTurnStateFromSessionRecord(
return detectClaudeTurnState(parsed)
}

if (provider === 'cursor-agent') {
return null
}

return detectCodexTurnState(parsed)
}

Expand Down
8 changes: 7 additions & 1 deletion src/contexts/agent/presentation/main-ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { listInstalledAgentProviders } from '../../infrastructure/cli/AgentCliAv
import {
disposeAgentModelService,
listAgentModels,
resolveDefaultModelDisplayName,
} from '../../infrastructure/cli/AgentModelService'
import { captureGeminiSessionDiscoveryCursor } from '../../infrastructure/cli/AgentSessionLocatorProviders'
import { locateAgentResumeSessionId } from '../../infrastructure/cli/AgentSessionLocator'
Expand Down Expand Up @@ -275,13 +276,18 @@ export function registerAgentIpcHandlers(
})
}

let displayModel = launchCommand.effectiveModel
if (!displayModel && normalized.provider === 'cursor-agent') {
displayModel = await resolveDefaultModelDisplayName('cursor-agent')
}

const result: LaunchAgentResult = {
sessionId,
provider: normalized.provider,
command: resolvedInvocation.command,
args: resolvedInvocation.args,
launchMode: launchCommand.launchMode,
effectiveModel: launchCommand.effectiveModel,
effectiveModel: displayModel,
resumeSessionId,
}

Expand Down
7 changes: 7 additions & 0 deletions src/contexts/settings/domain/agentSettings.providerMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const AGENT_PROVIDER_LABEL: Record<AgentProvider, string> = {
codex: 'Codex',
opencode: 'OpenCode',
gemini: 'Gemini CLI',
'cursor-agent': 'Cursor Agent',
}

export interface AgentProviderCapabilities {
Expand Down Expand Up @@ -39,4 +40,10 @@ export const AGENT_PROVIDER_CAPABILITIES: Record<AgentProvider, AgentProviderCap
runtimeObservation: 'none',
experimental: false,
},
'cursor-agent': {
taskTitle: false,
worktreeNameSuggestion: false,
runtimeObservation: 'none',
experimental: true,
},
}
Loading