Skip to content

Commit 1d03f18

Browse files
authored
feat(cursor): support model selection (#684)
1 parent db934a9 commit 1d03f18

26 files changed

Lines changed: 597 additions & 19 deletions

cli/src/cursor/cursorRemoteLauncher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase {
103103
cwd: session.path,
104104
sessionId: cursorSessionId,
105105
mode: agentMode,
106-
model: session.model,
106+
model: mode.model,
107107
yolo
108108
});
109109

cli/src/cursor/runCursor.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export async function runCursor(opts: {
6767
const sessionWrapperRef: { current: CursorSession | null } = { current: null };
6868

6969
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
70-
const currentModel = opts.model;
70+
let currentModel = opts.model;
7171

7272
const lifecycle = createRunnerLifecycle({
7373
session,
@@ -85,7 +85,9 @@ export async function runCursor(opts: {
8585
return;
8686
}
8787
sessionInstance.setPermissionMode(currentPermissionMode);
88-
logger.debug(`[cursor] Synced session permission mode: ${currentPermissionMode}`);
88+
sessionInstance.setModel(currentModel);
89+
sessionInstance.pushKeepAlive();
90+
logger.debug(`[cursor] Synced session mode: permissionMode=${currentPermissionMode}, model=${currentModel}`);
8991
};
9092

9193
session.onUserMessage((message, localId) => {
@@ -106,12 +108,15 @@ export async function runCursor(opts: {
106108
registerSessionConfigRpc<PermissionMode>({
107109
rpcHandlerManager: session.rpcHandlerManager,
108110
flavor: 'cursor',
109-
modelMode: 'ignore',
111+
modelMode: 'nullable',
110112
appliedFallback: () => ({ permissionMode: currentPermissionMode }),
111113
onApply: (config) => {
112114
if (config.permissionMode !== undefined) {
113115
currentPermissionMode = config.permissionMode;
114116
}
117+
if (config.model !== undefined) {
118+
currentModel = config.model ?? undefined;
119+
}
115120
},
116121
onAfterApply: syncSessionMode
117122
});

cli/src/cursor/session.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type LocalLaunchFailure = {
1111

1212
export class CursorSession extends AgentSessionBase<EnhancedMode> {
1313
readonly cursorArgs?: string[];
14-
readonly model?: string;
14+
model?: string;
1515
readonly startedBy: 'runner' | 'terminal';
1616
readonly startingMode: 'local' | 'remote';
1717
localLaunchFailure: LocalLaunchFailure | null = null;
@@ -60,6 +60,10 @@ export class CursorSession extends AgentSessionBase<EnhancedMode> {
6060
this.permissionMode = mode;
6161
};
6262

63+
setModel = (model: string | null | undefined): void => {
64+
this.model = model ?? undefined;
65+
};
66+
6367
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
6468
this.localLaunchFailure = { message, exitReason };
6569
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { parseCursorModelsOutput } from './cursorModels'
3+
4+
describe('parseCursorModelsOutput', () => {
5+
test('parses Cursor agent model list output', () => {
6+
const result = parseCursorModelsOutput(`
7+
Available models
8+
9+
auto - Auto
10+
composer-2.5 - Composer 2.5 (current)
11+
composer-2.5-fast - Composer 2.5 Fast (default)
12+
gpt-5.5-high-fast - GPT-5.5 High Fast
13+
14+
Tip: use --model <id> (or /model <id> in interactive mode) to switch.
15+
`)
16+
17+
expect(result).toEqual({
18+
availableModels: [
19+
{ modelId: 'auto', name: 'Auto' },
20+
{ modelId: 'composer-2.5', name: 'Composer 2.5' },
21+
{ modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' },
22+
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
23+
],
24+
currentModelId: 'composer-2.5'
25+
})
26+
})
27+
28+
test('uses default as current when Cursor output has no current marker', () => {
29+
const result = parseCursorModelsOutput(`
30+
Available models
31+
composer-2.5-fast - Composer 2.5 Fast (default)
32+
composer-2.5 - Composer 2.5
33+
`)
34+
35+
expect(result.currentModelId).toBe('composer-2.5-fast')
36+
})
37+
})
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { spawn } from 'node:child_process';
2+
import type { CursorModelsResponse, CursorModelSummary } from '@hapi/protocol/apiTypes';
3+
import { getErrorMessage } from './rpcResponses';
4+
5+
export type ListCursorModelsResponse = CursorModelsResponse;
6+
7+
interface CacheEntry {
8+
expiresAt: number;
9+
response: ListCursorModelsResponse;
10+
}
11+
12+
const CACHE_TTL_MS = 60_000;
13+
const PROBE_TIMEOUT_MS = 30_000;
14+
const cache: CacheEntry = {
15+
expiresAt: 0,
16+
response: { success: true, availableModels: [], currentModelId: null }
17+
};
18+
let inflight: Promise<ListCursorModelsResponse> | null = null;
19+
20+
export function parseCursorModelsOutput(output: string): {
21+
availableModels: CursorModelSummary[];
22+
currentModelId: string | null;
23+
} {
24+
const availableModels: CursorModelSummary[] = [];
25+
let currentModelId: string | null = null;
26+
27+
for (const rawLine of output.split(/\r?\n/)) {
28+
const line = rawLine.trim();
29+
if (!line || line === 'Available models' || line.startsWith('Tip:')) {
30+
continue;
31+
}
32+
33+
const separatorIndex = line.indexOf(' - ');
34+
if (separatorIndex <= 0) {
35+
continue;
36+
}
37+
38+
const modelId = line.slice(0, separatorIndex).trim();
39+
const rawName = line.slice(separatorIndex + 3).trim();
40+
if (!modelId || !rawName) {
41+
continue;
42+
}
43+
44+
const isCurrent = /\s*\(current\)\s*$/.test(rawName);
45+
const isDefault = /\s*\(default\)\s*$/.test(rawName);
46+
const name = rawName.replace(/\s*\((?:current|default)\)\s*$/, '').trim();
47+
availableModels.push(name && name !== modelId ? { modelId, name } : { modelId });
48+
49+
if (isCurrent) {
50+
currentModelId = modelId;
51+
} else if (isDefault && currentModelId === null) {
52+
currentModelId = modelId;
53+
}
54+
}
55+
56+
return { availableModels, currentModelId };
57+
}
58+
59+
async function runCursorModelProbe(): Promise<ListCursorModelsResponse> {
60+
return await new Promise((resolve, reject) => {
61+
const child = spawn('agent', ['--list-models'], {
62+
env: process.env,
63+
stdio: ['ignore', 'pipe', 'pipe'],
64+
shell: process.platform === 'win32'
65+
});
66+
let stdout = '';
67+
let stderr = '';
68+
let settled = false;
69+
70+
const timeout = setTimeout(() => {
71+
if (settled) return;
72+
settled = true;
73+
child.kill('SIGTERM');
74+
reject(new Error('Cursor model discovery timed out'));
75+
}, PROBE_TIMEOUT_MS);
76+
77+
child.stdout?.on('data', (chunk) => {
78+
stdout += chunk.toString();
79+
});
80+
child.stderr?.on('data', (chunk) => {
81+
stderr += chunk.toString();
82+
});
83+
child.on('error', (error) => {
84+
if (settled) return;
85+
settled = true;
86+
clearTimeout(timeout);
87+
reject(error);
88+
});
89+
child.on('exit', (code) => {
90+
if (settled) return;
91+
settled = true;
92+
clearTimeout(timeout);
93+
if (code !== 0) {
94+
reject(new Error(stderr.trim() || `agent --list-models exited with code ${code}`));
95+
return;
96+
}
97+
98+
resolve({
99+
success: true,
100+
...parseCursorModelsOutput(stdout)
101+
});
102+
});
103+
});
104+
}
105+
106+
export async function listCursorModels(): Promise<ListCursorModelsResponse> {
107+
if (cache.expiresAt > Date.now()) {
108+
return cache.response;
109+
}
110+
111+
if (inflight) {
112+
return inflight;
113+
}
114+
115+
inflight = (async () => {
116+
try {
117+
const response = await runCursorModelProbe();
118+
cache.expiresAt = Date.now() + CACHE_TTL_MS;
119+
cache.response = response;
120+
return response;
121+
} catch (error) {
122+
return {
123+
success: false,
124+
error: getErrorMessage(error, 'Failed to discover Cursor models')
125+
};
126+
} finally {
127+
inflight = null;
128+
}
129+
})();
130+
131+
return inflight;
132+
}
133+
134+
export function _resetCursorModelsCacheForTests(): void {
135+
cache.expiresAt = 0;
136+
cache.response = { success: true, availableModels: [], currentModelId: null };
137+
inflight = null;
138+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { logger } from '@/ui/logger';
2+
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
3+
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager';
4+
import {
5+
listCursorModels,
6+
type ListCursorModelsResponse
7+
} from '../cursorModels';
8+
import { getErrorMessage, rpcError } from '../rpcResponses';
9+
10+
export function registerCursorModelHandlers(rpcHandlerManager: RpcHandlerManager): void {
11+
rpcHandlerManager.registerHandler<Record<string, never>, ListCursorModelsResponse>(
12+
RPC_METHODS.ListCursorModels,
13+
async () => {
14+
logger.debug('List Cursor models request');
15+
16+
try {
17+
return await listCursorModels();
18+
} catch (error) {
19+
logger.debug('Failed to list Cursor models:', error);
20+
return rpcError(getErrorMessage(error, 'Failed to list Cursor models'));
21+
}
22+
}
23+
);
24+
}

cli/src/modules/common/registerCommonHandlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
22
import { registerBashHandlers } from './handlers/bash'
33
import { registerCodexModelHandlers } from './handlers/codexModels'
4+
import { registerCursorModelHandlers } from './handlers/cursorModels'
45
import { registerOpencodeModelHandlers } from './handlers/opencodeModels'
56
import { registerDirectoryHandlers } from './handlers/directories'
67
import { registerDifftasticHandlers } from './handlers/difftastic'
@@ -14,6 +15,7 @@ import { registerUploadHandlers } from './handlers/uploads'
1415
export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
1516
registerBashHandlers(rpcHandlerManager, workingDirectory)
1617
registerCodexModelHandlers(rpcHandlerManager)
18+
registerCursorModelHandlers(rpcHandlerManager)
1719
registerOpencodeModelHandlers(rpcHandlerManager)
1820
registerFileHandlers(rpcHandlerManager, workingDirectory)
1921
registerDirectoryHandlers(rpcHandlerManager, workingDirectory)

hub/src/sync/rpcGateway.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type {
44
CodexModelSummary,
55
CodexModelsResponse,
66
CommandResponse,
7+
CursorModelSummary,
8+
CursorModelsResponse,
79
DeleteUploadResponse,
810
DirectoryEntry,
911
FileReadResponse,
@@ -31,6 +33,8 @@ export type RpcListDirectoryResponse = ListDirectoryResponse
3133
export type RpcPathExistsResponse = PathExistsResponse
3234
export type RpcCodexModel = CodexModelSummary
3335
export type RpcListCodexModelsResponse = CodexModelsResponse
36+
export type RpcCursorModel = CursorModelSummary
37+
export type RpcListCursorModelsResponse = CursorModelsResponse
3438
export type RpcOpencodeModel = OpencodeModelSummary
3539
export type RpcListOpencodeModelsResponse = OpencodeModelsResponse
3640

@@ -238,6 +242,14 @@ export class RpcGateway {
238242
return await this.machineRpc(machineId, RPC_METHODS.ListCodexModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCodexModelsResponse
239243
}
240244

245+
async listCursorModelsForSession(sessionId: string): Promise<RpcListCursorModelsResponse> {
246+
return await this.sessionRpc(sessionId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse
247+
}
248+
249+
async listCursorModelsForMachine(machineId: string): Promise<RpcListCursorModelsResponse> {
250+
return await this.machineRpc(machineId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse
251+
}
252+
241253
async listOpencodeModelsForSession(sessionId: string): Promise<RpcListOpencodeModelsResponse> {
242254
return await this.sessionRpc(sessionId, RPC_METHODS.ListOpencodeModels, {}) as RpcListOpencodeModelsResponse
243255
}

hub/src/sync/syncEngine.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import {
2626
type RpcGeneratedImageResponse,
2727
type RpcListDirectoryResponse,
2828
type RpcListCodexModelsResponse,
29+
type RpcListCursorModelsResponse,
2930
type RpcListOpencodeModelsResponse,
31+
type RpcCursorModel,
3032
type RpcOpencodeModel,
3133
type RpcPathExistsResponse,
3234
type RpcReadFileResponse,
@@ -44,7 +46,9 @@ export type {
4446
RpcGeneratedImageResponse,
4547
RpcListDirectoryResponse,
4648
RpcListCodexModelsResponse,
49+
RpcListCursorModelsResponse,
4750
RpcListOpencodeModelsResponse,
51+
RpcCursorModel,
4852
RpcOpencodeModel,
4953
RpcPathExistsResponse,
5054
RpcReadFileResponse,
@@ -906,6 +910,14 @@ export class SyncEngine {
906910
return await this.rpcGateway.listCodexModelsForMachine(machineId)
907911
}
908912

913+
async listCursorModelsForSession(sessionId: string): Promise<RpcListCursorModelsResponse> {
914+
return await this.rpcGateway.listCursorModelsForSession(sessionId)
915+
}
916+
917+
async listCursorModelsForMachine(machineId: string): Promise<RpcListCursorModelsResponse> {
918+
return await this.rpcGateway.listCursorModelsForMachine(machineId)
919+
}
920+
909921
async listOpencodeModelsForSession(sessionId: string): Promise<RpcListOpencodeModelsResponse> {
910922
return await this.rpcGateway.listOpencodeModelsForSession(sessionId)
911923
}

hub/src/web/routes/machines.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,39 @@ describe('machines routes', () => {
120120
currentModelId: 'ollama/exaone:4.5-33b-q8'
121121
})
122122
})
123+
124+
it('returns Cursor models for an online machine', async () => {
125+
const machine = createMachine()
126+
const engine = {
127+
getMachine: () => machine,
128+
getMachineByNamespace: () => machine,
129+
listCursorModelsForMachine: async () => ({
130+
success: true,
131+
availableModels: [
132+
{ modelId: 'composer-2.5', name: 'Composer 2.5' },
133+
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }
134+
],
135+
currentModelId: 'composer-2.5'
136+
})
137+
} as Partial<SyncEngine>
138+
139+
const app = new Hono<WebAppEnv>()
140+
app.use('*', async (c, next) => {
141+
c.set('namespace', 'default')
142+
await next()
143+
})
144+
app.route('/api', createMachinesRoutes(() => engine as SyncEngine))
145+
146+
const response = await app.request('/api/machines/machine-1/cursor-models')
147+
148+
expect(response.status).toBe(200)
149+
expect(await response.json()).toEqual({
150+
success: true,
151+
availableModels: [
152+
{ modelId: 'composer-2.5', name: 'Composer 2.5' },
153+
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }
154+
],
155+
currentModelId: 'composer-2.5'
156+
})
157+
})
123158
})

0 commit comments

Comments
 (0)